App redesign (#2946)

* Start of app redesign

* format

* continue progress

* Content page nearly done

* Fix recursion issues with content page

* Fix update all alignment

* Discover page progress

* Settings progress

* Removed unlocked-size hack that breaks web

* Revamp project page, refactor web project page to share code with app, fixed loading bar, misc UI/UX enhancements, update ko-fi logo, update arrow icons, fix web issues caused by floating-vue migration, fix tooltip issues, update web tooltips, clean up web hydration issues

* Ads + run prettier

* Begin auth refactor, move common messages to ui lib, add i18n extraction to all apps, begin Library refactor

* fix ads not hiding when plus log in

* rev lockfile changes/conflicts

* Fix sign in page

* Add generated

* (mostly) Data driven search

* Fix search mobile issue

* profile fixes

* Project versions page, fix typescript on UI lib and misc fixes

* Remove unused gallery component

* Fix linkfunction err

* Search filter controls at top, localization for locked filters

* Fix provided filter names

* Fix navigating from instance browse to main browse

* Friends frontend (#2995)

* Friends system frontend

* (almost) finish frontend

* finish friends, fix lint

* Fix lint

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>

* Refresh macOS app icon

* Update web search UI more

* Fix link opens

* Fix frontend build

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
Prospector
2024-12-11 19:54:18 -08:00
committed by GitHub
parent 6ec1dcf088
commit c39bb78e38
257 changed files with 15713 additions and 9475 deletions

View File

@@ -0,0 +1,91 @@
<template>
<div v-bind="$attrs">
<button
v-if="!!slots.title"
:class="buttonClass ?? 'flex flex-col gap-2'"
@click="() => (isOpen ? close() : open())"
>
<slot name="button" :open="isOpen">
<div class="flex items-center w-full">
<slot name="title" />
<DropdownIcon
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
<slot name="summary" />
</button>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { ref, useSlots } from 'vue'
const props = withDefaults(
defineProps<{
openByDefault?: boolean
type?: 'standard' | 'outlined' | 'transparent'
buttonClass?: string
contentClass?: string
titleWrapperClass?: string
}>(),
{
type: 'standard',
openByDefault: false,
},
)
const isOpen = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
function open() {
isOpen.value = true
emit('onOpen')
}
function close() {
isOpen.value = false
emit('onClose')
}
defineExpose({
open,
close,
isOpen,
})
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<router-link v-if="to.path || to.query || to.startsWith('/')" :to="to" v-bind="$attrs">
<slot />
</router-link>
<a v-else-if="to.startsWith('http')" :href="to" v-bind="$attrs">
<slot />
</a>
<span v-else v-bind="$attrs">
<slot />
</span>
</template>
<script setup lang="ts">
defineProps<{
to: any
}>()
defineOptions({
inheritAttrs: false,
})
</script>

View File

@@ -74,7 +74,7 @@
</span>
</template>
<script setup>
<script setup lang="ts">
import {
ModrinthIcon,
ScaleIcon,
@@ -172,16 +172,10 @@ const messages = defineMessages({
})
const { formatMessage } = useVIntl()
defineProps({
type: {
type: String,
required: true,
},
color: {
type: String,
default: '',
},
})
defineProps<{
type: string
color?: string
}>()
</script>
<style lang="scss" scoped>
.version-badge {

View File

@@ -126,7 +126,7 @@ function setColorFill(
const colorVariables = computed(() => {
if (props.highlighted) {
let colors = {
const colors = {
bg:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand-highlight)'
@@ -137,7 +137,7 @@ const colorVariables = computed(() => {
? 'var(--color-brand)'
: 'var(--color-contrast)',
}
let hoverColors = JSON.parse(JSON.stringify(colors))
const hoverColors = JSON.parse(JSON.stringify(colors))
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
}
@@ -186,6 +186,7 @@ const colorVariables = computed(() => {
}
/* Searches up to 4 children deep for valid button */
.btn-wrapper :deep(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
@@ -194,7 +195,7 @@ const colorVariables = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
@@ -202,6 +203,11 @@ const colorVariables = computed(() => {
svg:first-child {
color: var(--_icon, var(--_text));
transition: color 0.25s ease-in-out;
}
&:hover svg:first-child {
color: var(--_hover-text);
}
&[disabled],
@@ -222,6 +228,7 @@ const colorVariables = computed(() => {
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
@@ -234,6 +241,7 @@ const colorVariables = computed(() => {
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :deep(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
@@ -256,6 +264,7 @@ const colorVariables = computed(() => {
gap: 1px;
> .btn-wrapper:not(:first-child) {
:deep(:is(button, a, .button-like):first-child),
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
@@ -266,6 +275,7 @@ const colorVariables = computed(() => {
}
> :not(:last-child) {
:deep(:is(button, a, .button-like):first-child),
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,

View File

@@ -6,14 +6,15 @@
@click="toggle"
>
<button
class="checkbox"
class="checkbox border-none"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description"
:aria-checked="modelValue"
>
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<MinusIcon v-if="indeterminate" aria-hidden="true" />
<CheckIcon v-else-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
</button>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
@@ -24,7 +25,7 @@
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
import { CheckIcon, DropdownIcon, MinusIcon } from '@modrinth/assets'
const emit = defineEmits<{
'update:modelValue': [boolean]
@@ -32,12 +33,13 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
label: string
label?: string
disabled?: boolean
description: string
description?: string
modelValue: boolean
clickEvent?: () => void
collapsingToggleStyle?: boolean
indeterminate?: boolean
}>(),
{
label: '',
@@ -46,6 +48,7 @@ const props = withDefaults(
modelValue: false,
clickEvent: () => {},
collapsingToggleStyle: false,
indeterminate: false,
},
)
@@ -78,7 +81,6 @@ function toggle() {
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
min-width: 1rem;
min-height: 1rem;
@@ -95,10 +97,14 @@ function toggle() {
&.checked {
background-color: var(--color-brand);
svg {
color: var(--color-accent-contrast);
}
}
svg {
color: var(--color-accent-contrast);
color: var(--color-secondary);
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;

View File

@@ -1,21 +0,0 @@
<template>
<router-link v-if="isLink" :to="to">
<slot />
</router-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
class="grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4 lg:grid-cols-[1fr_auto]"
>
<div class="flex gap-4">
<slot name="icon" />
@@ -11,10 +11,10 @@
</h1>
<slot name="title-suffix" />
</div>
<p class="m-0 line-clamp-2 max-w-[40rem]">
<p class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
<slot name="summary" />
</p>
<div class="mt-auto flex flex-wrap gap-4">
<div class="mt-auto flex flex-wrap gap-4 empty:hidden">
<slot name="stats" />
</div>
</div>

View File

@@ -22,7 +22,13 @@
}"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<div>
<slot :selected="selectedOption">
<span>
{{ selectedOption }}
</span>
</slot>
</div>
<DropdownIcon class="arrow" :class="{ rotate: dropdownVisible }" />
</div>
<div class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">

View File

@@ -0,0 +1,113 @@
<template>
<div class="w-full flex items-center justify-center flex-col gap-2">
<div class="title">Loading</div>
<div class="placeholder"></div>
<div class="placeholder"></div>
<div class="placeholder"></div>
</div>
</template>
<script setup></script>
<style scoped>
.title {
position: absolute;
z-index: 1;
font-weight: bold;
color: var(--color-contrast);
&::after {
content: '';
animation: dots 2s infinite;
}
}
@keyframes dots {
25% {
content: '';
}
50% {
content: '.';
}
75% {
content: '..';
}
0%,
100% {
content: '...';
}
}
.placeholder {
border-radius: var(--radius-lg);
width: 100%;
height: 4rem;
opacity: 0.25;
position: relative;
overflow: hidden;
background-color: var(--color-raised-bg);
animation: pop 4s ease-in-out infinite;
border: 1px solid transparent;
&::before {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(
-45deg,
transparent 30%,
rgba(196, 217, 237, 0.075) 50%,
transparent 70%
);
animation: shimmer 4s ease-in-out infinite;
}
&:nth-child(2)::before {
animation-delay: 0s;
}
&:nth-child(3)::before {
animation-delay: 0.3s;
}
&:nth-child(4)::before {
animation-delay: 0.6s;
}
&:nth-child(2) {
animation-delay: 0s;
}
&:nth-child(3) {
animation-delay: 0.3s;
}
&:nth-child(4) {
animation-delay: 0.6s;
}
}
@keyframes pop {
from {
opacity: 0.25;
border-color: transparent;
}
50% {
opacity: 0.5;
border-color: var(--color-button-bg);
}
to {
opacity: 0.25;
border-color: transparent;
}
}
@keyframes shimmer {
from {
transform: translateX(-80%);
}
50%,
to {
transform: translateX(80%);
}
}
</style>

View File

@@ -6,6 +6,7 @@
:disabled="disabled"
:position="position"
:direction="direction"
:dropdown-id="dropdownId"
@open="
() => {
searchQuery = ''
@@ -15,15 +16,15 @@
<slot />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #menu>
<div class="iconified-input mb-2 w-full" v-if="search">
<div v-if="search" class="iconified-input mb-2 w-full">
<label for="search-input" hidden>Search...</label>
<SearchIcon aria-hidden="true" />
<input
id="search-input"
ref="searchInput"
v-model="searchQuery"
placeholder="Search..."
type="text"
ref="searchInput"
@keydown.enter="
() => {
toggleOption(filteredOptions[0])
@@ -40,7 +41,7 @@
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
<CheckIcon
class="h-5 w-5 text-contrast ml-auto transition-opacity"
:class="{ 'opacity-0': !manyValues.includes(option) }"
@@ -56,7 +57,7 @@
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<slot name="option" :option="option">{{ displayName?.(option) }}</slot>
<CheckIcon
class="h-5 w-5 text-contrast ml-auto transition-opacity"
:class="{ 'opacity-0': !manyValues.includes(option) }"
@@ -85,6 +86,7 @@ const props = withDefaults(
direction?: string
displayName?: (option: Option) => string
search?: boolean
dropdownId?: string
}>(),
{
disabled: false,

View File

@@ -368,7 +368,7 @@ onMounted(() => {
if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) {
// If the user is pasting a file, upload it if there's an included handler and insert the link.
uploadImagesFromList(clipboardData.files)
// eslint-disable-next-line func-names -- who the fuck did this?
.then(function (url) {
const selection = markdownCommands.yankSelection(view)
const altText = selection || 'Replace this with a description'

View File

@@ -1,5 +1,5 @@
<template>
<div class="vue-notification-group">
<div class="vue-notification-group" :class="{ 'has-sidebar': sidebar }">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
@@ -20,6 +20,13 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
sidebar: {
type: Boolean,
default: false,
},
})
const notifications = ref([])
defineExpose({
@@ -90,6 +97,10 @@ function stopTimer(notif) {
z-index: 99999999;
width: 300px;
&.has-sidebar {
right: 325px;
}
.vue-notification-wrapper {
width: 100%;
overflow: hidden;

View File

@@ -3,8 +3,8 @@
ref="dropdown"
v-bind="$attrs"
:disabled="disabled"
:position="position"
:direction="direction"
:dropdown-id="dropdownId"
:tooltip="tooltip"
>
<slot></slot>
<template #menu>
@@ -17,10 +17,12 @@
<Button
v-else
:key="`option-${option.id}`"
v-tooltip="option.tooltip"
:color="option.color ? option.color : 'default'"
:hover-filled="option.hoverFilled"
:hover-filled-only="option.hoverFilledOnly"
transparent
:v-close-popper="!option.remainOnClick"
:action="
option.action
? (event) => {
@@ -33,6 +35,7 @@
"
:link="option.link ? option.link : null"
:external="option.external ? option.external : false"
:disabled="option.disabled"
@click="
() => {
if (option.link && !option.remainOnClick) {
@@ -80,6 +83,8 @@ interface Item extends BaseOption {
hoverFilled?: boolean
hoverFilledOnly?: boolean
remainOnClick?: boolean
disabled?: boolean
tooltip?: string
}
type Option = Divider | Item
@@ -88,14 +93,14 @@ const props = withDefaults(
defineProps<{
options: Option[]
disabled?: boolean
position?: string
direction?: string
dropdownId?: string
tooltip?: string
}>(),
{
options: () => [],
disabled: false,
position: 'auto',
direction: 'auto',
dropdownId: null,
tooltip: null,
},
)
@@ -106,7 +111,6 @@ defineOptions({
const dropdown = ref(null)
const close = () => {
console.log('closing!')
dropdown.value.hide()
}
</script>

View File

@@ -2,12 +2,16 @@
<div v-if="count > 1" class="flex items-center gap-1">
<ButtonStyled v-if="page > 1" circular type="transparent">
<a
v-if="linkFunction"
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="switchPage(page - 1)"
>
<ChevronLeftIcon />
</a>
<button v-else aria-label="Previous Page" @click="switchPage(page - 1)">
<ChevronLeftIcon />
</button>
</ButtonStyled>
<div
v-for="(item, index) in pages"
@@ -27,20 +31,29 @@
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
>
<a :href="linkFunction(item)" @click.prevent="page !== item ? switchPage(item) : null">
<a
v-if="linkFunction"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
<button v-else @click="page !== item ? switchPage(item) : null">{{ item }}</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
<a
v-if="linkFunction"
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="switchPage(page + 1)"
>
<ChevronRightIcon />
</a>
<button v-else aria-label="Next Page" @click="switchPage(page + 1)">
<ChevronRightIcon />
</button>
</ButtonStyled>
</div>
</template>
@@ -68,7 +81,7 @@ const props = withDefaults(
)
const pages = computed(() => {
let pages: ('-' | number)[] = []
const pages: ('-' | number)[] = []
const first = 1
const last = props.count

View File

@@ -1,275 +1,91 @@
<template>
<div ref="dropdown" class="popup-container" tabindex="-1" :aria-expanded="dropdownVisible">
<button
v-bind="$attrs"
ref="dropdownButton"
:class="{ 'popout-open': dropdownVisible }"
:tabindex="tabInto ? -1 : 0"
@click="toggleDropdown"
>
<Dropdown
ref="dropdown"
theme="ribbit-popout"
no-auto-focus
:aria-id="dropdownId || null"
@hide="focusTrigger"
@apply-show="focusMenuChild"
>
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
<slot></slot>
</button>
<div
class="popup-menu"
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
:inert="!tabInto && !dropdownVisible"
>
<slot name="menu"> </slot>
</div>
</div>
<template #popper="{ hide }">
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
<div ref="menu" class="contents">
<slot name="menu"> </slot>
</div>
<button class="dummy-button" @focusin="hideAndFocusTrigger(hide)"></button>
</template>
</Dropdown>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Dropdown } from 'floating-vue'
import { ref, defineOptions } from 'vue'
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
position: {
const trigger = ref()
const menu = ref()
const dropdown = ref()
defineProps({
dropdownId: {
type: String,
default: 'auto',
default: null,
required: false,
},
direction: {
tooltip: {
type: String,
default: 'auto',
},
tabInto: {
type: Boolean,
default: false,
default: null,
required: false,
},
})
function focusMenuChild() {
setTimeout(() => {
if (menu.value && menu.value.children && menu.value.children.length > 0) {
menu.value.children[0].focus()
}
}, 50)
}
function hideAndFocusTrigger(hide) {
hide()
focusTrigger()
}
function focusTrigger() {
trigger.value.focus()
}
defineOptions({
inheritAttrs: false,
})
const emit = defineEmits(['open', 'close'])
const dropdownVisible = ref(false)
const dropdown = ref(null)
const dropdownButton = ref(null)
const computedPosition = ref('bottom')
const computedDirection = ref('left')
function updateDirection() {
if (props.direction === 'auto') {
if (dropdownButton.value) {
const x = dropdownButton.value.getBoundingClientRect().left
computedDirection.value = x < window.innerWidth / 2 ? 'right' : 'left'
} else {
computedDirection.value = 'left'
}
} else {
computedDirection.value = props.direction
}
if (props.position === 'auto') {
if (dropdownButton.value) {
const y = dropdownButton.value.getBoundingClientRect().top
computedPosition.value = y < window.innerHeight / 2 ? 'bottom' : 'top'
} else {
computedPosition.value = 'bottom'
}
} else {
computedPosition.value = props.position
}
function hide() {
dropdown.value.hide()
}
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
if (dropdownVisible.value) {
emit('open')
} else {
dropdownButton.value.focus()
emit('close')
}
}
}
const hide = () => {
dropdownVisible.value = false
dropdownButton.value.focus()
emit('close')
}
const show = () => {
dropdownVisible.value = true
emit('open')
function show() {
dropdown.value.show()
}
defineExpose({
show,
hide,
})
const handleClickOutside = (event) => {
if (!dropdown.value) return
const isContextMenuClick = event.button === 2 || event.which === 3
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
(dropdown.value !== event.target &&
!elements.includes(dropdown.value) &&
!dropdown.value.contains(event.target)) ||
isContextMenuClick
) {
dropdownVisible.value = false
emit('close')
}
}
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
hide()
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener('mouseup', handleClickOutside)
window.addEventListener('resize', updateDirection)
window.addEventListener('scroll', updateDirection)
window.addEventListener('keydown', handleKeyDown)
updateDirection()
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('mouseup', handleClickOutside)
window.removeEventListener('resize', updateDirection)
window.removeEventListener('scroll', updateDirection)
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style lang="scss" scoped>
.popup-container {
position: relative;
.popup-menu {
--_animation-offset: -1rem;
position: absolute;
scale: 0.75;
border: 1px solid var(--color-button-bg);
padding: var(--gap-sm);
width: fit-content;
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
z-index: 10;
opacity: 0;
transition:
bottom 0.125s ease-in-out,
top 0.125s ease-in-out,
left 0.125s ease-in-out,
right 0.125s ease-in-out,
opacity 0.125s ease-in-out,
scale 0.125s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.position-bottom-left {
top: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-left-up {
bottom: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-left-down {
top: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-up {
bottom: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-down {
top: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&:not(.visible):not(:focus-within) {
pointer-events: none;
*,
::before,
::after {
pointer-events: none;
}
}
&.visible,
&:focus-within {
opacity: 1;
scale: 1;
&.position-bottom-left {
top: calc(100% + var(--gap-sm));
right: 0;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm));
left: 0;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm));
right: 0;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm));
left: 0;
}
&.position-left-up {
bottom: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-left-down {
top: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-right-up {
bottom: 0rem;
left: calc(100% + var(--gap-sm));
}
&.position-right-down {
top: 0rem;
left: calc(100% + var(--gap-sm));
}
}
.btn {
white-space: nowrap;
}
}
<style scoped>
.dummy-button {
position: absolute;
width: 0;
height: 0;
margin: 0;
padding: 0;
border: none;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
outline: none;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
import { ref } from 'vue'
withDefaults(
defineProps<{
checked: boolean
}>(),
{
checked: false,
},
)
</script>
<template>
<div class="" role="button" @click="() => {}">
<slot name="preview" />
<div>
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
<RadioButtonChecked v-else class="w-4 h-4" />
<slot />
</div>
</div>
</template>

View File

@@ -74,7 +74,7 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime.js'
import { defineComponent } from 'vue'
import Categories from '../search/Categories.vue'
import Badge from './Badge.vue'
import Badge from './SimpleBadge.vue'
import Avatar from './Avatar.vue'
import EnvironmentIndicator from './EnvironmentIndicator.vue'
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="scrollable-pane-wrapper" :class="{ 'max-height': !props.noMaxHeight }">
<div class="scrollable-pane-wrapper">
<div
class="wrapper-wrapper"
:class="{
'top-fade': !scrollableAtTop && !props.noMaxHeight,
'bottom-fade': !scrollableAtBottom && !props.noMaxHeight,
'top-fade': !scrollableAtTop && !props.disableScrolling,
'bottom-fade': !scrollableAtBottom && !props.disableScrolling,
}"
>
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
@@ -19,10 +19,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
noMaxHeight?: boolean
disableScrolling?: boolean
}>(),
{
noMaxHeight: false,
disableScrolling: false,
},
)
@@ -49,7 +49,7 @@ onUnmounted(() => {
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop === 0
scrollableAtTop.value = scrollTop <= 0
}
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
updateFade(scrollTop, offsetHeight, scrollHeight)
@@ -61,47 +61,26 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
display: flex;
flex-direction: column;
position: relative;
&.max-height {
max-height: 19rem;
}
}
.wrapper-wrapper {
flex-grow: 1;
display: flex;
overflow: hidden;
position: relative;
--_fade-height: 4rem;
margin-bottom: var(--gap-sm);
&.top-fade::before,
&.bottom-fade::after {
opacity: 1;
&.top-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
}
&::before,
&::after {
content: '';
left: 0;
right: 0;
opacity: 0;
position: absolute;
pointer-events: none;
transition: opacity 0.125s ease;
height: var(--_fade-height);
z-index: 1;
&.bottom-fade {
mask-image: linear-gradient(rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
}
&::before {
top: 0;
background-image: linear-gradient(
var(--scrollable-pane-bg, var(--color-raised-bg)),
transparent
);
}
&::after {
bottom: 0;
background-image: linear-gradient(
transparent,
var(--scrollable-pane-bg, var(--color-raised-bg))
);
&.top-fade.bottom-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height), rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%);
}
}
.scrollable-pane {
@@ -113,26 +92,5 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
overflow-y: auto;
overflow-x: hidden;
position: relative;
::-webkit-scrollbar {
transition: all;
}
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
padding: 4px;
border: 3px solid var(--color-bg);
}
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<span
class="inline-flex items-center gap-1 font-semibold text-secondary"
>
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
{{ formattedName }}
</span>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
icon?: Component
formattedName: string
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
}>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<button
v-if="action"
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4 border-none transition-transform active:scale-[0.95] cursor-pointer hover:underline"
@click="action"
>
<slot />
</button>
<div
v-else
class="bg-[--_bg-color,var(--color-button-bg)] px-2 py-1 leading-none rounded-full font-semibold text-sm inline-flex items-center gap-1 text-[--_color,var(--color-secondary)] [&>svg]:shrink-0 [&>svg]:h-4 [&>svg]:w-4"
>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
action?: (event: MouseEvent) => void
}>()
</script>

View File

@@ -942,7 +942,7 @@ async function submitPayment() {
defineExpose({
show: () => {
// eslint-disable-next-line no-undef
stripe = Stripe(props.publishableKey)
selectedPlan.value = 'yearly'

View File

@@ -0,0 +1,91 @@
<script setup lang="ts" generic="T">
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import Checkbox from '../base/Checkbox.vue'
import type { RouteLocationRaw } from 'vue-router'
import { SlashIcon } from '@modrinth/assets'
import { ref } from 'vue'
export interface ContentCreator {
name: string
type: 'user' | 'organization'
id: string
link?: string | RouteLocationRaw
linkProps?: any
}
export interface ContentProject {
id: string
link?: string | RouteLocationRaw
linkProps?: any
}
export interface ContentItem<T> {
path: string
disabled: boolean
filename: string
data: T
icon?: string
title?: string
project?: ContentProject
creator?: ContentCreator
version?: string
versionId?: string
}
withDefaults(
defineProps<{
item: ContentItem<T>
last?: boolean
}>(),
{
last: false,
},
)
const model = defineModel()
</script>
<template>
<div
class="grid grid-cols-[min-content,4fr,3fr,2fr] gap-3 items-center p-2 h-[64px] border-solid border-0 border-b-button-bg relative"
:class="{ 'border-b-[1px]': !last }"
>
<Checkbox v-model="model" :description="``" class="select-checkbox" />
<div
class="flex items-center gap-2 text-contrast font-medium"
:class="{ 'opacity-50': item.disabled }"
>
<AutoLink :to="item.project?.link ?? ''" tabindex="-1" v-bind="item.project?.linkProps ?? {}">
<Avatar :src="item.icon ?? ''" :class="{ grayscale: item.disabled }" size="48px" />
</AutoLink>
<div class="flex flex-col">
<AutoLink :to="item.project?.link ?? ''" v-bind="item.project?.linkProps ?? {}">
<div class="text-contrast line-clamp-1" :class="{ 'line-through': item.disabled }">
{{ item.title ?? item.filename }}
</div>
</AutoLink>
<AutoLink :to="item.creator?.link ?? ''" v-bind="item.creator?.linkProps ?? {}">
<div class="line-clamp-1 break-all" :class="{ 'opacity-50': item.disabled }">
<slot v-if="item.creator && item.creator.name" :item="item">
<span class="text-secondary"> by {{ item.creator.name }} </span>
</slot>
</div>
</AutoLink>
</div>
</div>
<div class="flex flex-col max-w-60" :class="{ 'opacity-50': item.disabled }">
<div v-if="item.version" class="line-clamp-1 break-all">
<slot :creator="item.creator">
{{ item.version }}
</slot>
</div>
<div class="text-secondary text-xs line-clamp-1 break-all">{{ item.filename }}</div>
</div>
<div class="flex justify-end gap-1">
<slot name="actions" :item="item" />
</div>
</div>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts" generic="T">
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import Checkbox from '../base/Checkbox.vue'
import ContentListItem from './ContentListItem.vue'
import type { ContentItem } from './ContentListItem.vue'
import { DropdownIcon } from '@modrinth/assets'
// @ts-ignore
import { RecycleScroller } from 'vue-virtual-scroller'
const props = withDefaults(
defineProps<{
items: ContentItem<T>[]
sortColumn: string
sortAscending: boolean
updateSort: (column: string) => void
}>(),
{},
)
const selectionStates: Ref<Record<string, boolean>> = ref({})
const selected: Ref<string[]> = computed(() =>
Object.keys(selectionStates.value).filter(
(item) => selectionStates.value[item] && props.items.some((x) => x.filename === item),
),
)
const allSelected = ref(false)
const model = defineModel()
function updateSelection() {
model.value = selected.value
}
function setSelected(value: boolean) {
if (value) {
selectionStates.value = Object.fromEntries(props.items.map((item) => [item.filename, true]))
} else {
selectionStates.value = {}
}
updateSelection()
}
</script>
<template>
<div class="flex flex-col grid-cols-[min-content,auto,auto,auto,auto]">
<div
:class="`${$slots.headers ? 'flex' : 'grid'} grid-cols-[min-content,4fr,3fr,2fr] gap-3 items-center px-2 pt-1 h-10 mb-3 text-contrast font-bold`"
>
<Checkbox
v-model="allSelected"
class="select-checkbox"
:indeterminate="selected.length > 0 && selected.length < items.length"
@update:model-value="setSelected"
/>
<slot name="headers">
<div class="flex items-center gap-2 cursor-pointer" @click="updateSort('Name')">
Name
<DropdownIcon
v-if="sortColumn === 'Name'"
class="transition-all transform"
:class="{ 'rotate-180': sortAscending }"
/>
</div>
<div class="flex items-center gap-1 max-w-60 cursor-pointer" @click="updateSort('Updated')">
Updated
<DropdownIcon
v-if="sortColumn === 'Updated'"
class="transition-all transform"
:class="{ 'rotate-180': sortAscending }"
/>
</div>
<div class="flex justify-end gap-2">
<slot name="header-actions" />
</div>
</slot>
</div>
<div class="bg-bg-raised rounded-xl">
<RecycleScroller
v-slot="{ item, index }"
:items="items"
:item-size="64"
disable-transform
key-field="filename"
style="height: 100%"
>
<ContentListItem
v-model="selectionStates[item.filename]"
:item="item"
:last="props.items.length - 1 === index"
class="mb-2"
@update:model-value="updateSelection"
>
<template #actions="{ item }">
<slot name="actions" :item="item" />
</template>
</ContentListItem>
</RecycleScroller>
</div>
</div>
</template>

View File

@@ -1,4 +1,6 @@
// Base content
export { default as Accordion } from './base/Accordion.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 Button } from './base/Button.vue'
@@ -6,7 +8,6 @@ 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 ConditionalNuxtLink } from './base/ConditionalNuxtLink.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'
@@ -14,6 +15,7 @@ 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 FileInput } from './base/FileInput.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 Notifications } from './base/Notifications.vue'
@@ -21,8 +23,10 @@ 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 ProjectCard } from './base/ProjectCard.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as SimpleBadge } from './base/SimpleBadge.vue'
export { default as Slider } from './base/Slider.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as Toggle } from './base/Toggle.vue'
@@ -35,6 +39,9 @@ export { default as TextLogo } from './brand/TextLogo.vue'
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'
// Modals
export { default as NewModal } from './modal/NewModal.vue'
export { default as Modal } from './modal/Modal.vue'
@@ -47,13 +54,34 @@ export { default as NavItem } from './nav/NavItem.vue'
export { default as NavRow } from './nav/NavRow.vue'
export { default as NavStack } from './nav/NavStack.vue'
// Project
export { default as NewProjectCard } from './project/NewProjectCard.vue'
export { default as ProjectBackgroundGradient } from './project/ProjectBackgroundGradient.vue'
export { default as ProjectHeader } from './project/ProjectHeader.vue'
export { default as ProjectPageDescription } from './project/ProjectPageDescription.vue'
export { default as ProjectPageVersions } from './project/ProjectPageVersions.vue'
export { default as ProjectSidebarCompatibility } from './project/ProjectSidebarCompatibility.vue'
export { default as ProjectSidebarCreators } from './project/ProjectSidebarCreators.vue'
export { default as ProjectSidebarDetails } from './project/ProjectSidebarDetails.vue'
export { default as ProjectSidebarLinks } from './project/ProjectSidebarLinks.vue'
export { default as ProjectStatusBadge } from './project/ProjectStatusBadge.vue'
// 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'
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.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'

View File

@@ -7,7 +7,7 @@
:class="{ shown: visible }"
class="tauri-overlay"
data-tauri-drag-region
@click="() => (closable ? hide() : {})"
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div
:class="{
@@ -16,12 +16,12 @@
danger: danger,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
@click="() => (closeOnClickOutside && closable ? hide() : {})"
/>
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-button-bg max-w-full"
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
<slot name="title">
@@ -31,7 +31,7 @@
</slot>
</div>
<ButtonStyled v-if="closable" circular>
<button @click="hide" aria-label="Close">
<button aria-label="Close" @click="hide">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
@@ -56,6 +56,7 @@ const props = withDefaults(
closable?: boolean
danger?: boolean
closeOnEsc?: boolean
closeOnClickOutside?: boolean
warnOnClose?: boolean
header?: string
onHide?: () => void
@@ -65,6 +66,7 @@ const props = withDefaults(
type: true,
closable: true,
danger: false,
closeOnClickOutside: true,
closeOnEsc: true,
warnOnClose: false,
onHide: () => {},
@@ -161,7 +163,13 @@ function handleKeyDown(event: KeyboardEvent) {
opacity: 0;
transition: all 0.2s ease-out;
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
filter: blur(5px);
//transform: translate(
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
// )
// scaleX(0.8) scaleY(0.5);
border-radius: 180px;
//filter: blur(5px);
@media (prefers-reduced-motion) {
transition: none !important;

View File

@@ -0,0 +1,85 @@
<template>
<div class="button-base p-4 bg-bg-raised rounded-xl flex gap-3 group">
<div class="icon">
<Avatar :src="project.icon_url" size="96px" class="search-icon" />
</div>
<div class="flex flex-col gap-2 overflow-hidden">
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
<span class="text-lg font-extrabold text-contrast m-0 leading-none">{{
project.title
}}</span>
<span v-if="project.author" class="text-secondary"> by {{ project.author }}</span>
</div>
<div class="m-0 line-clamp-2">
{{ project.description }}
</div>
<div class="mt-auto flex items-center gap-1 no-wrap">
<TagsIcon class="h-4 w-4 shrink-0" />
<div
v-for="tag in categories"
:key="tag"
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
>
{{ formatCategory(tag) }}
</div>
</div>
</div>
<div class="flex flex-col gap-2 items-end shrink-0 ml-auto">
<div class="flex items-center gap-2">
<DownloadIcon class="shrink-0" />
<span>
{{ formatNumber(project.downloads) }}
<span class="text-secondary">downloads</span>
</span>
</div>
<div class="flex items-center gap-2">
<HeartIcon class="shrink-0" />
<span>
{{ formatNumber(project.follows ?? project.followers) }}
<span class="text-secondary">followers</span>
</span>
</div>
<div class="mt-auto relative">
<div
:class="{
'group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all':
$slots.actions,
}"
class="flex items-center gap-2"
>
<HistoryIcon class="shrink-0" />
<span>
<span class="text-secondary">Updated</span>
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
</span>
</div>
<div
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
>
<slot name="actions" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { TagsIcon, DownloadIcon, HeartIcon, HistoryIcon } from '@modrinth/assets'
import Avatar from '../base/Avatar.vue'
import { formatNumber, formatCategory } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)
defineProps({
project: {
type: Object,
required: true,
},
categories: {
type: Array,
required: true,
},
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div :style="`--_color: ${color}`" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
project: {
body: string,
color: number,
}
}>(),
{
},
)
function clamp (value: number) {
return Math.max(0, Math.min(255, value));
}
function toHex (value: number) {
return clamp(value).toString(16).padStart(2, '0');
}
function decimalToHexColor(decimal: number) {
const r = (decimal >> 16) & 255;
const g = (decimal >> 8) & 255;
const b = decimal & 255;
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
const color = computed(() => {
return decimalToHexColor(props.project.color)
})
</script>
<style scoped lang="scss">
div {
width: 100%;
height: 60rem;
background: linear-gradient(to bottom, var(--_color), transparent);
opacity: 0.075;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<ContentPageHeader>
<template #icon>
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
</template>
<template #title>
{{ project.title }}
</template>
<template #title-suffix>
<ProjectStatusBadge
v-if="member || project.status !== 'approved'"
:status="project.status"
/>
</template>
<template #summary>
{{ project.description }}
</template>
<template #stats>
<div
v-tooltip="
`${formatNumber(project.downloads, false)} download${project.downloads !== 1 ? 's' : ''}`
"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold cursor-help"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatNumber(project.downloads) }}
</div>
<div
v-tooltip="
`${formatNumber(project.followers, false)} follower${project.downloads !== 1 ? 's' : ''}`
"
class="flex items-center gap-2 border-0 border-solid border-divider pr-4 md:border-r cursor-help"
>
<HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold">
{{ formatNumber(project.followers) }}
</span>
</div>
<div class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
>
{{ formatCategory(category) }}
</TagItem>
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</template>
<script setup lang="ts">
import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
import Badge from '../base/SimpleBadge.vue'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
withDefaults(
defineProps<{
project: Project
member?: boolean
}>(),
{
member: false,
},
)
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
</template>
<script setup lang="ts">
import { renderHighlightedString } from '@modrinth/utils'
withDefaults(
defineProps<{
description: string,
}>(),
{
},
)
</script>

View File

@@ -0,0 +1,289 @@
<template>
<div class="mb-3 flex flex-wrap gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="versions"
:game-versions="gameVersions"
:base-id="`${baseId}-filter`"
@update:query="updateQuery"
/>
<Pagination
:page="currentPage"
class="ml-auto mt-auto"
:count="Math.ceil(filteredVersions.length / pageSize)"
@switch-page="switchPage"
/>
</div>
<div
v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
>
<div class="versions-grid-row">
<div class="w-9 max-sm:hidden"></div>
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Game version
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Platforms
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Published
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Downloads
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
Compatibility
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
<div class="w-9 max-sm:hidden"></div>
</div>
<template v-for="(version, index) in currentVersions" :key="index">
<!-- Row divider -->
<div
class="versions-grid-row h-px w-full bg-button-bg"
:class="{
'max-sm:!hidden': index === 0,
}"
></div>
<div class="versions-grid-row group relative">
<AutoLink
v-if="!!versionLink"
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
:to="versionLink?.(version)"
/>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row items-center gap-2 sm:contents">
<div class="self-center">
<div class="relative z-[1] cursor-pointer">
<VersionChannelIndicator
v-tooltip="`Toggle filter for ${version.version_type}`"
:channel="version.version_type"
@click="versionFilters?.toggleFilter('channel', version.version_type)"
/>
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center"
:class="{
'group-hover:underline': !!versionLink,
}"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="gameVersion in formatVersionsForDisplay(version.game_versions, gameVersions)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="z-[1]"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</TagItem>
</div>
</div>
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<svg v-html="loaders.find((x) => x.name === platform)?.icon"></svg>
{{ formatCategory(platform) }}
</TagItem>
</div>
</div>
</div>
<div
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
>
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(version.date_published),
time: new Date(version.date_published),
})
"
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ dayjs(version.date_published).fromNow() }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
>
<DownloadIcon class="xl:hidden" />
{{ formatNumber(version.downloads) }}
</div>
</div>
</div>
</div>
<div class="flex items-start justify-end gap-1 sm:items-center z-[1]">
<slot name="actions" :version="version"></slot>
</div>
<div
v-if="showFiles"
class="tag-list pointer-events-none relative z-[1] col-span-full"
>
<div
v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`"
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
>
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
{{ file.filename }} - {{ formatBytes(file.size) }}
</div>
</div>
</div>
</template>
</div>
<div class="flex mt-3">
<Pagination
:page="currentPage"
class="ml-auto"
:count="Math.ceil(filteredVersions.length / pageSize)"
@switch-page="switchPage"
/>
</div>
</template>
<script setup lang="ts">
import {
formatBytes,
formatCategory, formatNumber,
formatVersionsForDisplay,
type GameVersionTag, type PlatformTag, type Version
} from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages'
import {
CalendarIcon,
DownloadIcon,
StarIcon,
} from '@modrinth/assets'
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
import { useVIntl } from '@vintl/vintl'
import { type Ref, ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import dayjs from 'dayjs'
import AutoLink from '../base/AutoLink.vue'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
}
const props = withDefaults(
defineProps<{
baseId?: string,
project: {
project_type: string
slug?: string
id: string
},
versions: VersionWithDisplayUrlEnding[],
showFiles?: boolean,
currentMember?: boolean,
loaders: PlatformTag[],
gameVersions: GameVersionTag[],
versionLink?: (version: Version) => string,
}>(),
{
baseId: undefined,
showFiles: false,
currentMember: false,
versionLink: undefined,
},
)
const currentPage: Ref<number> = ref(1);
const pageSize: Ref<number> = ref(20);
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
const selectedGameVersions: Ref<string[]> = computed(() => versionFilters.value?.selectedGameVersions ?? []);
const selectedPlatforms: Ref<string[]> = computed(() => versionFilters.value?.selectedPlatforms ?? []);
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? []);
const filteredVersions = computed(() => {
return props.versions.filter(
(version) =>
hasAnySelected(version.game_versions, selectedGameVersions.value) &&
hasAnySelected(version.loaders, selectedPlatforms.value) &&
isAnySelected(version.version_type, selectedChannels.value)
);
});
function hasAnySelected(values: string[], selected: string[]) {
return selected.length === 0 || selected.some((value) => values.includes(value))
}
function isAnySelected(value: string, selected: string[]) {
return selected.length === 0 || selected.includes(value)
}
const currentVersions = computed(() =>
filteredVersions.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value));
const route = useRoute();
const router = useRouter();
if (route.query.page) {
currentPage.value = Number(route.query.page) || 1;
}
function switchPage(page: number) {
currentPage.value = page;
router.replace({
query: {
...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) {
if (newQueries.page) {
currentPage.value = Number(newQueries.page);
} else if (newQueries.page === undefined) {
currentPage.value = 1;
}
router.replace({
query: {
...route.query,
...newQueries,
},
});
}
</script>
<style scoped>
.versions-grid-row {
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<div v-if="project.versions.length > 0" class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<section class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.minecraftJava) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="version in getVersionsToDisplay(project, tags.gameVersions)"
:key="`version-tag-${version}`"
>
{{ version }}
</TagItem>
</div>
</section>
<section v-if="project.project_type !== 'resourcepack'" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.platforms) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</TagItem>
</div>
</section>
<section
v-if="
(project.actualProjectType === 'mod' || project.project_type === 'modpack') &&
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
!(project.client_side === 'unknown' && project.server_side === 'unknown')
"
class="flex flex-col gap-2"
>
<h3 class="text-primary text-base m-0">{{ formatMessage(messages.environments) }}</h3>
<div class="flex flex-wrap gap-1">
<TagItem
v-if="
(project.client_side === 'required' && project.server_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
>
<ClientIcon aria-hidden="true" />
Client-side
</TagItem>
<TagItem
v-if="
(project.server_side === 'required' && project.client_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
>
<ServerIcon aria-hidden="true" />
Server-side
</TagItem>
<TagItem v-if="false">
<UserIcon aria-hidden="true" />
Singleplayer
</TagItem>
<TagItem
v-if="
project.project_type !== 'datapack' &&
project.client_side !== 'unsupported' && project.server_side !== 'unsupported' && project.client_side !== 'unknown' && project.server_side !== 'unknown'
"
>
<MonitorSmartphoneIcon aria-hidden="true" />
Client and server
</TagItem>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useVIntl, defineMessages } from '@vintl/vintl'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'
defineProps<{
project: {
actualProjectType: string
project_type: string
loaders: string[]
client_side: EnvironmentValue
server_side: EnvironmentValue
versions: any[]
}
tags: {
gameVersions: GameVersionTag[]
loaders: PlatformTag[]
}
}>()
const messages = defineMessages({
title: {
id: 'project.about.compatibility.title',
defaultMessage: 'Compatibility',
},
minecraftJava: {
id: 'project.about.compatibility.game.minecraftJava',
defaultMessage: 'Minecraft: Java Edition',
},
platforms: {
id: 'project.about.compatibility.platforms',
defaultMessage: 'Platforms',
},
environments: {
id: 'project.about.compatibility.environments',
defaultMessage: 'Supported environments',
},
})
</script>

View File

@@ -0,0 +1,118 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold">
<template v-if="organization">
<AutoLink
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="orgLink(organization.slug)"
:target="linkTarget ?? null"
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="flex flex-col flex-nowrap justify-center">
<span class="group-hover:underline">
{{ organization.name }}
</span>
<span class="text-secondary text-sm font-medium flex items-center gap-1"
><OrganizationIcon /> Organization</span
>
</div>
</AutoLink>
<hr v-if="sortedMembers.length > 0" class="w-full border-button-border my-0.5" />
</template>
<AutoLink
v-for="member in sortedMembers"
:key="`member-${member.id}`"
class="flex gap-2 items-center w-fit text-primary leading-[1.2] group"
:to="userLink(member.user.username)"
:target="linkTarget ?? null"
>
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="32px" circle />
<div class="flex flex-col">
<span class="flex flex-row flex-nowrap items-center gap-1 group-hover:underline">
{{ member.user.username }}
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(messages.owner)"
class="text-brand-orange"
/>
<ExternalIcon v-if="linkTarget === '_blank'" />
</span>
<span class="text-secondary text-sm font-medium">{{ member.role }}</span>
</div>
</AutoLink>
</div>
</div>
</template>
<script setup lang="ts">
import { CrownIcon, ExternalIcon, OrganizationIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
import Avatar from '../base/Avatar.vue'
import AutoLink from '../base/AutoLink.vue'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
type TeamMember = {
id: string
role: string
is_owner: boolean
accepted: boolean
user: {
id: string
username: string
avatar_url: string
}
}
const props = defineProps<{
organization?: {
id: string
slug: string
name: string
icon_url: string
avatar_url: string
members: TeamMember[]
} | null
members: TeamMember[]
orgLink: (slug: string) => string
userLink: (username: string) => string
linkTarget?: string
}>()
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const sortedMembers = computed(() => {
const acceptedMembers = props.members.filter((x) => x.accepted === undefined || x.accepted)
const owner = acceptedMembers.find((x) =>
props.organization
? props.organization.members.some(
(orgMember) => orgMember.user.id === x.user.id && orgMember.is_owner,
)
: x.is_owner,
)
const rest = acceptedMembers.filter((x) => !owner || x.user.id !== owner.user.id) || []
rest.sort((a, b) => {
if (a.role === b.role) {
return a.user.username.localeCompare(b.user.username)
} else {
return a.role.localeCompare(b.role)
}
})
return owner ? [owner, ...rest] : rest
})
const messages = defineMessages({
title: {
id: 'project.about.creators.title',
defaultMessage: 'Creators',
},
owner: {
id: 'project.about.creators.owner',
defaultMessage: 'Project owner',
},
})
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div class="flex flex-col gap-3 font-semibold [&>div]:flex [&>div]:gap-2 [&>div]:items-center">
<div>
<BookTextIcon aria-hidden="true" />
<div>
Licensed
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.published, { date: publishedDate }) }}
</div>
</div>
<div v-else v-tooltip="dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')">
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.created, { date: createdDate }) }}
</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.submitted, { date: submittedDate }) }}
</div>
</div>
<div
v-if="hasVersions && project.updated"
v-tooltip="dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.updated, { date: updatedDate }) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { BookTextIcon, CalendarIcon, ScaleIcon, VersionIcon, ExternalIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
const { formatMessage } = useVIntl()
const props = defineProps<{
project: {
id: string
published: string
updated: string
approved: string
queued: string
status: string
license: {
id: string
url: string
}
}
linkTarget: string
hasVersions: boolean
}>()
const createdDate = computed(() =>
props.project.published ? dayjs(props.project.published).fromNow() : 'unknown',
)
const submittedDate = computed(() =>
props.project.queued ? dayjs(props.project.queued).fromNow() : 'unknown',
)
const publishedDate = computed(() =>
props.project.approved ? dayjs(props.project.approved).fromNow() : 'unknown',
)
const updatedDate = computed(() =>
props.project.updated ? dayjs(props.project.updated).fromNow() : 'unknown',
)
const licenseIdDisplay = computed(() => {
const id = props.project.license.id
if (id === 'LicenseRef-All-Rights-Reserved') {
return 'ARR'
} else if (id.includes('LicenseRef')) {
return id.replaceAll('LicenseRef-', '').replaceAll('-', ' ')
} else {
return id
}
})
const messages = defineMessages({
title: {
id: 'project.about.details.title',
defaultMessage: 'Details',
},
licensed: {
id: 'project.about.details.licensed',
defaultMessage: 'Licensed {license}',
},
created: {
id: 'project.about.details.created',
defaultMessage: 'Created {date}',
},
submitted: {
id: 'project.about.details.submitted',
defaultMessage: 'Submitted {date}',
},
published: {
id: 'project.about.details.published',
defaultMessage: 'Published {date}',
},
updated: {
id: 'project.about.details.updated',
defaultMessage: 'Updated {date}',
},
})
</script>

View File

@@ -0,0 +1,173 @@
<template>
<div
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0
"
class="flex flex-col gap-3"
>
<h2 class="text-lg m-0">{{ formatMessage(messages.title) }}</h2>
<div
class="flex flex-col gap-3 font-semibold [&>a]:flex [&>a]:gap-2 [&>a]:items-center [&>a]:w-fit [&>a]:text-primary [&>a]:leading-[1.2] [&>a:hover]:underline"
>
<a
v-if="project.issues_url"
:href="project.issues_url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<IssuesIcon aria-hidden="true" />
{{ formatMessage(messages.issues) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.source_url"
:href="project.source_url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<CodeIcon aria-hidden="true" />
{{ formatMessage(messages.source) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<WikiIcon aria-hidden="true" />
{{ formatMessage(messages.wiki) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<DiscordIcon class="shrink" aria-hidden="true" />
{{ formatMessage(messages.discord) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<hr
v-if="
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
project.donation_urls.length > 0
"
class="w-full border-button-border my-0.5"
/>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
:target="linkTarget"
rel="noopener nofollow ugc"
>
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
<HeartIcon v-else-if="donation.id === 'github'" />
<CurrencyIcon v-else />
<span v-if="donation.id === 'bmac'">{{ formatMessage(messages.donateBmac) }}</span>
<span v-else-if="donation.id === 'patreon'">{{
formatMessage(messages.donatePatreon)
}}</span>
<span v-else-if="donation.id === 'paypal'">{{ formatMessage(messages.donatePayPal) }}</span>
<span v-else-if="donation.id === 'ko-fi'">{{ formatMessage(messages.donateKoFi) }}</span>
<span v-else-if="donation.id === 'github'">{{ formatMessage(messages.donateGithub) }}</span>
<span v-else>{{ formatMessage(messages.donateGeneric) }}</span>
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
</div>
</div>
</template>
<script setup lang="ts">
import {
BuyMeACoffeeIcon,
CodeIcon,
CurrencyIcon,
DiscordIcon,
ExternalIcon,
HeartIcon,
IssuesIcon,
KoFiIcon,
OpenCollectiveIcon,
PatreonIcon,
PayPalIcon,
WikiIcon,
} from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps<{
project: {
issues_url: string
source_url: string
wiki_url: string
discord_url: string
donation_urls: {
id: string
url: string
}[]
}
linkTarget: string
}>()
const messages = defineMessages({
title: {
id: 'project.about.links.title',
defaultMessage: 'Links',
},
issues: {
id: 'project.about.links.issues',
defaultMessage: 'Report issues',
},
source: {
id: 'project.about.links.source',
defaultMessage: 'View source',
},
wiki: {
id: 'project.about.links.wiki',
defaultMessage: 'Visit wiki',
},
discord: {
id: 'project.about.links.discord',
defaultMessage: 'Join Discord server',
},
donateGeneric: {
id: 'project.about.links.donate.generic',
defaultMessage: 'Donate',
},
donateGitHub: {
id: 'project.about.links.donate.github',
defaultMessage: 'Sponsor on GitHub',
},
donateBmac: {
id: 'project.about.links.donate.bmac',
defaultMessage: 'Buy Me a Coffee',
},
donatePatreon: {
id: 'project.about.links.donate.patreon',
defaultMessage: 'Donate on Patreon',
},
donatePayPal: {
id: 'project.about.links.donate.paypal',
defaultMessage: 'Donate on PayPal',
},
donateKoFi: {
id: 'project.about.links.donate.kofi',
defaultMessage: 'Donate on Ko-fi',
},
donateGithub: {
id: 'project.about.links.donate.github',
defaultMessage: 'Sponsor on GitHub',
},
})
</script>

View File

@@ -0,0 +1,105 @@
<template>
<Badge :icon="metadata.icon" :formatted-name="metadata.formattedName" />
</template>
<script setup lang="ts">
import {
FileTextIcon,
ArchiveIcon,
UpdatedIcon,
LockIcon,
CalendarIcon,
GlobeIcon,
LinkIcon,
UnknownIcon, XIcon
} from '@modrinth/assets'
import { useVIntl, defineMessage, type MessageDescriptor } from '@vintl/vintl'
import type { Component } from 'vue'
import { computed } from 'vue'
import Badge from '../base/SimpleBadge.vue'
import type { ProjectStatus } from '@modrinth/utils'
const props = defineProps<{
status: ProjectStatus
}>()
const { formatMessage } = useVIntl()
const metadata = computed(() => ({
icon: statusMetadata[props.status]?.icon ?? statusMetadata.unknown.icon,
formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status),
}))
const statusMetadata: Record<ProjectStatus, { icon?: Component, message: MessageDescriptor }> = {
approved: {
icon: GlobeIcon,
message: defineMessage({
id: 'project.visibility.public',
defaultMessage: 'Public',
}),
},
unlisted: {
icon: LinkIcon,
message: defineMessage({
id: 'project.visibility.unlisted',
defaultMessage: 'Unlisted',
}),
},
withheld: {
icon: LinkIcon,
message: defineMessage({
id: 'project.visibility.unlisted-by-staff',
defaultMessage: 'Unlisted by staff',
}),
},
private: {
icon: LockIcon,
message: defineMessage({
id: 'project.visibility.private',
defaultMessage: 'Private',
}),
},
scheduled: {
icon: CalendarIcon,
message: defineMessage({
id: 'project.visibility.scheduled',
defaultMessage: 'Scheduled',
}),
},
draft: {
icon: FileTextIcon,
message: defineMessage({
id: 'project.visibility.draft',
defaultMessage: 'Draft',
}),
},
archived: {
icon: ArchiveIcon,
message: defineMessage({
id: 'project.visibility.archived',
defaultMessage: 'Archived',
}),
},
rejected: {
icon: XIcon,
message: defineMessage({
id: 'project.visibility.rejected',
defaultMessage: 'Rejected',
}),
},
processing: {
icon: UpdatedIcon,
message: defineMessage({
id: 'project.visibility.under-review',
defaultMessage: 'Under review',
}),
},
unknown: {
icon: UnknownIcon,
message: defineMessage({
id: 'project.visibility.unknown',
defaultMessage: 'Unknown',
}),
},
}
</script>

View File

@@ -0,0 +1,105 @@
<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 Accordion from '../base/Accordion.vue'
import { computed } from '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(() => {
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

@@ -0,0 +1,97 @@
<template>
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
<TagItem
v-if="selectedItems.length > 1"
class="transition-transform active:scale-[0.95]"
:action="clearFilters"
>
<XCircleIcon />
Clear all filters
</TagItem>
<TagItem
v-for="selectedItem in selectedItems"
:key="`remove-filter-${selectedItem.type}-${selectedItem.option}`"
:action="() => removeFilter(selectedItem)"
>
<XIcon />
<BanIcon v-if="selectedItem.negative" class="text-brand-red" />
{{ selectedItem.formatted_name ?? selectedItem.option }}
</TagItem>
<TagItem
v-for="providedItem in items.filter((x) => x.provided)"
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
>
<LockIcon />
{{ providedItem.formatted_name ?? providedItem.option }}
</TagItem>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon, XIcon, LockIcon, BanIcon } from '@modrinth/assets'
import { computed, type ComputedRef } from 'vue'
import TagItem from '../base/TagItem.vue'
import type { FilterValue, FilterType, FilterOption } from '../../utils/search'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const props = defineProps<{
filters: FilterType[]
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor
}>()
const defaultProvidedMessage = defineMessage({
id: 'search.filter.locked.default',
defaultMessage: 'Filter locked',
})
type Item = {
type: string
option: string
negative?: boolean
formatted_name?: string
provided: boolean
}
function filterMatches(type: FilterType, option: FilterOption, list: FilterValue[]) {
return list.some((provided) => provided.type === type.id && provided.option === option.id)
}
const items: ComputedRef<Item[]> = computed(() => {
return props.filters.flatMap((type) =>
type.options
.filter(
(option) =>
filterMatches(type, option, selectedFilters.value) ||
filterMatches(type, option, props.providedFilters),
)
.map((option) => ({
type: type.id,
option: option.id,
negative: selectedFilters.value.find((x) => x.type === type.id && x.option === option.id)
?.negative,
provided: filterMatches(type, option, props.providedFilters),
formatted_name: option.formatted_name,
})),
)
})
const selectedItems = computed(() => items.value.filter((x) => !x.provided))
function removeFilter(filter: Item) {
selectedFilters.value = selectedFilters.value.filter(
(x) => x.type !== filter.type || x.option !== filter.option,
)
}
async function clearFilters() {
selectedFilters.value = []
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="search-filter-option group flex gap-1 items-center">
<button
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)"
>
<slot>
</slot>
<BanIcon
v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
v-else
:class="`filter-action-icon check-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${included ? '' : '[@media(hover:hover)]:opacity-0'}`"
aria-hidden="true"
/>
</button>
<div v-if="supportsNegativeFilter && !excluded" class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents" :class="{ 'opacity-0': included }">
</div>
<button
v-if="supportsNegativeFilter && !excluded"
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
class="flex border-none cursor-pointer items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary [@media(hover:hover)]:opacity-0 transition-all hover:bg-button-bg hover:text-red focus-visible:bg-button-bg focus-visible:text-red active:scale-[0.96]"
@click="() => emit('toggleExclude', option)"
>
<BanIcon class="filter-action-icon h-4 w-4" aria-hidden="true" />
</button>
</div>
</template>
<script setup lang="ts">
import { BanIcon, CheckIcon } from '@modrinth/assets'
import type { FilterOption } from '../../utils/search'
withDefaults(defineProps<{
option: FilterOption
included: boolean
excluded: boolean
supportsNegativeFilter?: boolean
}>(), {
supportsNegativeFilter: false,
})
const emit = defineEmits<{
toggle: [option: FilterOption]
toggleExclude: [option: FilterOption]
}>()
</script>
<style scoped lang="scss">
.search-filter-option:hover,
.search-filter-option:has(button:focus-visible) {
button,
.filter-action-icon {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,334 @@
<template>
<Accordion
v-bind="$attrs"
ref="accordion"
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
:content-class="contentClass"
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
>
<template #title>
<slot name="header" :filter="filterType">
<h2>{{ filterType.formatted_name }}</h2>
</slot>
</template>
<template
v-if="
locked ||
(!!accordion &&
!accordion.isOpen &&
(selectedFilterOptions.length > 0 || selectedNegativeFilterOptions.length > 0))
"
#summary
>
<div class="flex gap-1 flex-wrap">
<div
v-for="option in selectedFilterOptions"
:key="`selected-filter-${filterType.id}-${option}`"
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
>
{{ option.formatted_name ?? option.id }}
</div>
<div
v-for="option in selectedNegativeFilterOptions"
:key="`excluded-filter-${filterType.id}-${option}`"
class="flex gap-1 text-xs bg-button-bg px-2 py-0.5 rounded-full font-bold text-secondary w-fit shrink-0 items-center"
>
<BanIcon class="text-brand-red" /> {{ option.formatted_name ?? option.id }}
</div>
</div>
</template>
<template v-if="locked" #default>
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
<p class="m-0 font-bold items-center">
<slot :name="`locked-${filterType.id}`" >
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
</slot>
</p>
<p class="m-0 text-secondary text-sm">
{{ formatMessage(messages.lockedDescription) }}
</p>
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes.push(filterType.id)
}
"
>
<LockOpenIcon />
{{ formatMessage(messages.unlockFilterButton) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else #default>
<div v-if="filterType.searchable" class="iconified-input mx-2 my-1 !flex">
<SearchIcon aria-hidden="true" />
<input
:id="`search-${filterType.id}`"
v-model="query"
class="!min-h-9 text-sm"
type="text"
:placeholder="`Search...`"
autocomplete="off"
/>
<Button v-if="query" class="r-btn" aria-label="Clear search" @click="() => (query = '')">
<XIcon aria-hidden="true" />
</Button>
</div>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<div v-if="typeof option.icon === 'string'" class="h-4 w-4" v-html="option.icon" />
<component :is="option.icon" v-else-if="option.icon" class="h-4 w-4" />
<span class="truncate text-sm">{{ option.formatted_name ?? option.id }}</span>
</slot>
</SearchFilterOption>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">{{ showMore ? 'Show fewer' : 'Show more' }}</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
:key="`toggle-group-${group.id}`"
class="mx-2"
:model-value="groupEnabled(group.id)"
:label="`${group.formatted_name}`"
@update:model-value="toggleGroup(group.id)"
/>
<div v-if="hasProvidedFilter" class="mt-2 mx-1">
<ButtonStyled>
<button
class="w-fit"
@click="
() => {
overriddenProvidedFilterTypes = overriddenProvidedFilterTypes.filter(
(id) => id !== filterType.id,
)
accordion?.close()
clearFilters()
}
"
>
<UpdatedIcon />
<slot name="sync-button">
{{ formatMessage(messages.syncFilterButton) }}
</slot>
</button>
</ButtonStyled>
</div>
</div>
</template>
</Accordion>
</template>
<script setup lang="ts">
import Accordion from '../base/Accordion.vue'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import {
BanIcon,
SearchIcon,
XIcon,
UpdatedIcon,
LockOpenIcon,
DropdownIcon,
} from '@modrinth/assets'
import { Button, Checkbox, ScrollablePanel } from '../index'
import { computed, ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import SearchFilterOption from './SearchFilterOption.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
const toggledGroups = defineModel<string[]>('toggledGroups', { required: true })
const overriddenProvidedFilterTypes = defineModel<string[]>('overriddenProvidedFilterTypes', {
required: false,
default: [],
})
const props = defineProps<{
filterType: FilterType
buttonClass?: string
contentClass?: string
innerPanelClass?: string
openByDefault?: boolean
providedFilters: FilterValue[]
}>()
defineOptions({
inheritAttrs: false,
})
const query = ref('')
const showMore = ref(false)
const accordion = ref<InstanceType<typeof Accordion> | null>()
const selectedFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
locked.value ? isProvided(option, false) : isIncluded(option),
),
)
const selectedNegativeFilterOptions = computed(() =>
props.filterType.options.filter((option) =>
locked.value ? isProvided(option, true) : isExcluded(option),
),
)
const visibleOptions = computed(() =>
props.filterType.options
.filter((option) => isVisible(option) || isIncluded(option) || isExcluded(option))
.slice()
.sort((a, b) => {
if (props.filterType.display === 'expandable') {
const aDefault = props.filterType.default_values.includes(a.id)
const bDefault = props.filterType.default_values.includes(b.id)
if (aDefault && !bDefault) {
return -1
} else if (!aDefault && bDefault) {
return 1
}
}
return 0
}),
)
const hasProvidedFilter = computed(() =>
props.providedFilters.some((filter) => filter.type === props.filterType.id),
)
const locked = computed(
() =>
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
)
const scrollable = computed(
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
)
function groupEnabled(group: string) {
return toggledGroups.value.includes(group)
}
function toggleGroup(group: string) {
if (toggledGroups.value.includes(group)) {
toggledGroups.value = toggledGroups.value.filter((x) => x !== group)
} else {
toggledGroups.value.push(group)
}
}
function isIncluded(filter: FilterOption) {
return selectedFilters.value.some((value) => value.option === filter.id && !value.negative)
}
function isExcluded(filter: FilterOption) {
return selectedFilters.value.some((value) => value.option === filter.id && value.negative)
}
function isVisible(filter: FilterOption) {
const filterKey = filter.formatted_name?.toLowerCase() ?? filter.id.toLowerCase()
const matchesQuery = !query.value || filterKey.includes(query.value.toLowerCase())
if (props.filterType.display === 'expandable') {
return matchesQuery && (showMore.value || props.filterType.default_values.includes(filter.id))
}
if (filter.toggle_group) {
return toggledGroups.value.includes(filter.toggle_group) && matchesQuery
} else {
return matchesQuery
}
}
function isProvided(filter: FilterOption, negative: boolean) {
return props.providedFilters.some(
(x) => x.type === props.filterType.id && x.option === filter.id && !x.negative === !negative,
)
}
type FilterState = 'include' | 'exclude' | 'ignore'
function toggleFilter(filter: FilterOption) {
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
}
function toggleNegativeFilter(filter: FilterOption) {
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
}
function setFilter(filter: FilterOption, state: FilterState) {
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
const baseValues = {
type: props.filterType.id,
option: filter.id,
}
if (state === 'include') {
newFilters.push({
...baseValues,
negative: false,
})
} else if (state === 'exclude') {
newFilters.push({
...baseValues,
negative: true,
})
}
selectedFilters.value = newFilters
}
function clearFilters() {
selectedFilters.value = selectedFilters.value.filter(
(filter) => filter.type !== props.filterType.id,
)
}
const messages = defineMessages({
unlockFilterButton: {
id: 'search.filter.locked.default.unlock',
defaultMessage: 'Unlock filter',
},
syncFilterButton: {
id: 'search.filter.locked.default.sync',
defaultMessage: 'Sync filter',
},
lockedTitle: {
id: 'search.filter.locked.default.title',
defaultMessage: '{type} is locked',
},
lockedDescription: {
id: 'search.filter.locked.default.description',
defaultMessage: 'Unlocking this filter may allow you to install incompatible content.',
},
})
</script>

View File

@@ -0,0 +1,145 @@
<script setup>
import { MoonIcon, RadioButtonChecked, RadioButtonIcon, SunIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl'
const { formatMessage } = useVIntl()
defineProps({
updateColorTheme: {
type: Function,
required: true,
},
currentTheme: {
type: String,
required: true,
},
themeOptions: {
type: Array,
required: true,
},
systemThemeColor: {
type: String,
required: true,
},
})
const colorTheme = defineMessages({
title: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
description: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
},
system: {
id: 'settings.display.theme.system',
defaultMessage: 'Sync with system',
},
light: {
id: 'settings.display.theme.light',
defaultMessage: 'Light',
},
dark: {
id: 'settings.display.theme.dark',
defaultMessage: 'Dark',
},
oled: {
id: 'settings.display.theme.oled',
defaultMessage: 'OLED',
},
retro: {
id: 'settings.display.theme.retro',
defaultMessage: 'Retro',
},
preferredLight: {
id: 'settings.display.theme.preferred-light-theme',
defaultMessage: 'Preferred light theme',
},
preferredDark: {
id: 'settings.display.theme.preferred-dark-theme',
defaultMessage: 'Preferred dark theme',
},
})
</script>
<template>
<div class="theme-options mt-4">
<button
v-for="option in themeOptions"
:key="option"
class="preview-radio button-base"
:class="{ selected: currentTheme === option }"
@click="() => updateColorTheme(option)"
>
<div class="preview" :class="`${option === 'system' ? systemThemeColor : option}-mode`">
<div class="example-card card card">
<div class="example-icon"></div>
<div class="example-text-1"></div>
<div class="example-text-2"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked v-if="currentTheme === option" class="radio" />
<RadioButtonIcon v-else class="radio" />
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
<SunIcon
v-if="'light' === option"
v-tooltip="formatMessage(colorTheme.preferredLight)"
class="theme-icon"
/>
<MoonIcon
v-else-if="'dark' === option"
v-tooltip="formatMessage(colorTheme.preferredDark)"
class="theme-icon"
/>
</div>
</button>
</div>
</template>
<style scoped lang="scss">
.theme-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
gap: var(--gap-lg);
.preview .example-card {
margin: 0;
padding: 1rem;
display: grid;
grid-template: 'icon text1' 'icon text2';
grid-template-columns: auto 1fr;
gap: 0.5rem;
outline: 2px solid transparent;
.example-icon {
grid-area: icon;
width: 2rem;
height: 2rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1,
.example-text-2 {
height: 0.5rem;
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1 {
grid-area: text1;
width: 100%;
background-color: var(--color-base);
}
.example-text-2 {
grid-area: text2;
width: 60%;
background-color: var(--color-secondary);
}
}
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed } from 'vue'
import type { VersionChannel } from '@modrinth/utils'
const { formatMessage } = useVIntl()
const props = withDefaults(
withDefaults(
defineProps<{
channel: 'release' | 'beta' | 'alpha'
channel: VersionChannel
large?: boolean
}>(),
{

View File

@@ -0,0 +1,212 @@
<template>
<div class="experimental-styles-within flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<ManySelect
v-model="selectedPlatforms"
:options="filterOptions.platform"
:dropdown-id="`${baseId}-platform`"
@change="updateFilters"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Platform
<template #option="{ option }">
{{ formatCategory(option) }}
</template>
</ManySelect>
<ManySelect
v-model="selectedGameVersions"
:options="filterOptions.gameVersion"
:dropdown-id="`${baseId}-game-version`"
search
@change="updateFilters"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Game versions
<template #footer>
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
</template>
</ManySelect>
<ManySelect
v-model="selectedChannels"
:options="filterOptions.channel"
:dropdown-id="`${baseId}-channel`"
@change="updateFilters"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Channels
<template #option="{ option }">
{{ option === 'release' ? 'Release' : option === 'beta' ? 'Beta' : 'Alpha' }}
</template>
</ManySelect>
</div>
<div class="flex flex-wrap items-center gap-1 empty:hidden">
<TagItem
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
class="transition-transform active:scale-[0.95]"
:action="clearFilters"
>
<XCircleIcon />
Clear all filters
</TagItem>
<TagItem
v-for="channel in selectedChannels"
:key="`remove-filter-${channel}`"
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
:action="() =>toggleFilter('channel', channel)"
>
<XIcon />
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
</TagItem>
<TagItem
v-for="version in selectedGameVersions"
:key="`remove-filter-${version}`"
:action="() =>toggleFilter('gameVersion', version)"
>
<XIcon />
{{ version }}
</TagItem>
<TagItem
v-for="platform in selectedPlatforms"
:key="`remove-filter-${platform}`"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => toggleFilter('platform', platform)"
>
<XIcon />
{{ formatCategory(platform) }}
</TagItem>
</div>
</div>
</template>
<script setup lang="ts">
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
import { ManySelect, Checkbox } from "../index";
import { type Version , formatCategory, type GameVersionTag } from '@modrinth/utils';
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import TagItem from '../base/TagItem.vue'
const props = defineProps<{
versions: Version[]
gameVersions: GameVersionTag[]
baseId?: string
}>();
const emit = defineEmits(["update:query"]);
const allChannels = ref(["release", "beta", "alpha"]);
const route = useRoute();
const showSnapshots = ref(false);
type FilterType = "channel" | "gameVersion" | "platform";
type Filter = string;
const filterOptions = computed(() => {
const filters: Record<FilterType, Filter[]> = {
channel: [],
gameVersion: [],
platform: [],
};
const platformSet = new Set();
const gameVersionSet = new Set();
const channelSet = new Set();
for (const version of props.versions) {
for (const loader of version.loaders) {
platformSet.add(loader);
}
for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion);
}
channelSet.add(version.version_type);
}
if (channelSet.size > 0) {
filters.channel = Array.from(channelSet) as Filter[];
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
}
if (gameVersionSet.size > 0) {
const gameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version));
filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => x.version);
}
if (platformSet.size > 0) {
filters.platform = Array.from(platformSet) as Filter[];
}
return filters;
});
const selectedChannels = ref<string[]>([]);
const selectedGameVersions = ref<string[]>([]);
const selectedPlatforms = ref<string[]>([]);
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
async function toggleFilters(type: FilterType, filters: Filter[]) {
for (const filter of filters) {
await toggleFilter(type, filter, true);
}
updateFilters();
}
async function toggleFilter(type: FilterType, filter: Filter, bulk = false) {
if (type === "channel") {
selectedChannels.value = selectedChannels.value.includes(filter)
? selectedChannels.value.filter((x) => x !== filter)
: [...selectedChannels.value, filter];
} else if (type === "gameVersion") {
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
? selectedGameVersions.value.filter((x) => x !== filter)
: [...selectedGameVersions.value, filter];
} else if (type === "platform") {
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
? selectedPlatforms.value.filter((x) => x !== filter)
: [...selectedPlatforms.value, filter];
}
if (!bulk) {
updateFilters();
}
}
async function clearFilters() {
selectedChannels.value = [];
selectedGameVersions.value = [];
selectedPlatforms.value = [];
updateFilters();
}
function updateFilters() {
emit("update:query", {
c: selectedChannels.value,
g: selectedGameVersions.value,
l: selectedPlatforms.value,
page: undefined,
});
}
defineExpose({
toggleFilter,
toggleFilters,
selectedChannels,
selectedGameVersions,
selectedPlatforms,
});
function getArrayOrString(x: string | string[]): string[] {
if (typeof x === "string") {
return [x];
} else {
return x;
}
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-divider bg-bg p-2"
>
<VersionChannelIndicator :channel="version.version_type" />
<div class="flex min-w-0 flex-col gap-1">
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
{{ version.name }}
</p>
</div>
<ButtonStyled color="brand">
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
<DownloadIcon aria-hidden="true" /> Download
</a>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
class="min-w-0"
aria-label="Open project page"
@click="emit('onNavigate')"
>
<ExternalIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, VersionChannelIndicator } from "../index";
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
import { computed } from "vue";
const props = defineProps<{
version: Version;
}>();
const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
return primary.url;
});
const emit = defineEmits(["onDownload", "onNavigate"]);
</script>

View File

@@ -1,4 +1,112 @@
{
"button.cancel": {
"defaultMessage": "Cancel"
},
"button.continue": {
"defaultMessage": "Continue"
},
"button.copy-id": {
"defaultMessage": "Copy ID"
},
"button.create-a-project": {
"defaultMessage": "Create a project"
},
"button.edit": {
"defaultMessage": "Edit"
},
"button.report": {
"defaultMessage": "Report"
},
"button.save": {
"defaultMessage": "Save"
},
"button.save-changes": {
"defaultMessage": "Save changes"
},
"button.sign-in": {
"defaultMessage": "Sign in"
},
"button.sign-out": {
"defaultMessage": "Sign out"
},
"button.upload-image": {
"defaultMessage": "Upload image"
},
"collection.label.private": {
"defaultMessage": "Private"
},
"input.view.gallery": {
"defaultMessage": "Gallery view"
},
"input.view.grid": {
"defaultMessage": "Grid view"
},
"input.view.list": {
"defaultMessage": "Rows view"
},
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
"label.collections": {
"defaultMessage": "Collections"
},
"label.created-ago": {
"defaultMessage": "Created {ago}"
},
"label.dashboard": {
"defaultMessage": "Dashboard"
},
"label.delete": {
"defaultMessage": "Delete"
},
"label.description": {
"defaultMessage": "Description"
},
"label.error": {
"defaultMessage": "Error"
},
"label.followed-projects": {
"defaultMessage": "Followed projects"
},
"label.moderation": {
"defaultMessage": "Moderation"
},
"label.notifications": {
"defaultMessage": "Notifications"
},
"label.password": {
"defaultMessage": "Password"
},
"label.public": {
"defaultMessage": "Public"
},
"label.rejected": {
"defaultMessage": "Rejected"
},
"label.scopes": {
"defaultMessage": "Scopes"
},
"label.servers": {
"defaultMessage": "Servers"
},
"label.settings": {
"defaultMessage": "Settings"
},
"label.title": {
"defaultMessage": "Title"
},
"label.unlisted": {
"defaultMessage": "Unlisted"
},
"label.visibility": {
"defaultMessage": "Visibility"
},
"label.visit-your-profile": {
"defaultMessage": "Visit your profile"
},
"notification.error.title": {
"defaultMessage": "An error occurred"
},
"omorphia.component.badge.label.accepted": {
"defaultMessage": "Accepted"
},
@@ -115,5 +223,227 @@
},
"omorphia.component.purchase_modal.payment_method_type.visa": {
"defaultMessage": "Visa"
},
"project-type.all": {
"defaultMessage": "All"
},
"project.about.compatibility.environments": {
"defaultMessage": "Supported environments"
},
"project.about.compatibility.game.minecraftJava": {
"defaultMessage": "Minecraft: Java Edition"
},
"project.about.compatibility.platforms": {
"defaultMessage": "Platforms"
},
"project.about.compatibility.title": {
"defaultMessage": "Compatibility"
},
"project.about.creators.owner": {
"defaultMessage": "Project owner"
},
"project.about.creators.title": {
"defaultMessage": "Creators"
},
"project.about.details.created": {
"defaultMessage": "Created {date}"
},
"project.about.details.licensed": {
"defaultMessage": "Licensed {license}"
},
"project.about.details.published": {
"defaultMessage": "Published {date}"
},
"project.about.details.submitted": {
"defaultMessage": "Submitted {date}"
},
"project.about.details.title": {
"defaultMessage": "Details"
},
"project.about.details.updated": {
"defaultMessage": "Updated {date}"
},
"project.about.links.discord": {
"defaultMessage": "Join Discord server"
},
"project.about.links.donate.bmac": {
"defaultMessage": "Buy Me a Coffee"
},
"project.about.links.donate.generic": {
"defaultMessage": "Donate"
},
"project.about.links.donate.github": {
"defaultMessage": "Sponsor on GitHub"
},
"project.about.links.donate.kofi": {
"defaultMessage": "Donate on Ko-fi"
},
"project.about.links.donate.patreon": {
"defaultMessage": "Donate on Patreon"
},
"project.about.links.donate.paypal": {
"defaultMessage": "Donate on PayPal"
},
"project.about.links.issues": {
"defaultMessage": "Report issues"
},
"project.about.links.source": {
"defaultMessage": "View source"
},
"project.about.links.title": {
"defaultMessage": "Links"
},
"project.about.links.wiki": {
"defaultMessage": "Visit wiki"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
"project.versions.channel.beta.symbol": {
"defaultMessage": "B"
},
"project.versions.channel.release.symbol": {
"defaultMessage": "R"
},
"project.visibility.archived": {
"defaultMessage": "Archived"
},
"project.visibility.draft": {
"defaultMessage": "Draft"
},
"project.visibility.private": {
"defaultMessage": "Private"
},
"project.visibility.public": {
"defaultMessage": "Public"
},
"project.visibility.rejected": {
"defaultMessage": "Rejected"
},
"project.visibility.scheduled": {
"defaultMessage": "Scheduled"
},
"project.visibility.under-review": {
"defaultMessage": "Under review"
},
"project.visibility.unknown": {
"defaultMessage": "Unknown"
},
"project.visibility.unlisted": {
"defaultMessage": "Unlisted"
},
"project.visibility.unlisted-by-staff": {
"defaultMessage": "Unlisted by staff"
},
"search.filter.locked.default": {
"defaultMessage": "Filter locked"
},
"search.filter.locked.default.description": {
"defaultMessage": "Unlocking this filter may allow you to install incompatible content."
},
"search.filter.locked.default.sync": {
"defaultMessage": "Sync filter"
},
"search.filter.locked.default.title": {
"defaultMessage": "{type} is locked"
},
"search.filter.locked.default.unlock": {
"defaultMessage": "Unlock filter"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
},
"search.filter_type.environment.client": {
"defaultMessage": "Client"
},
"search.filter_type.environment.server": {
"defaultMessage": "Server"
},
"search.filter_type.game_version": {
"defaultMessage": "Game version"
},
"search.filter_type.game_version.all_versions": {
"defaultMessage": "Show all versions"
},
"search.filter_type.license": {
"defaultMessage": "License"
},
"search.filter_type.license.open_source": {
"defaultMessage": "Open source"
},
"search.filter_type.mod_loader": {
"defaultMessage": "Loader"
},
"search.filter_type.modpack_loader": {
"defaultMessage": "Loader"
},
"search.filter_type.plugin_loader": {
"defaultMessage": "Loader"
},
"search.filter_type.plugin_platform": {
"defaultMessage": "Platform"
},
"search.filter_type.project_id": {
"defaultMessage": "Project ID"
},
"search.filter_type.shader_loader": {
"defaultMessage": "Loader"
},
"settings.account.title": {
"defaultMessage": "Account and security"
},
"settings.appearance.title": {
"defaultMessage": "Appearance"
},
"settings.applications.title": {
"defaultMessage": "Your applications"
},
"settings.authorized-apps.title": {
"defaultMessage": "Authorized apps"
},
"settings.billing.title": {
"defaultMessage": "Billing and subscriptions"
},
"settings.display.theme.dark": {
"defaultMessage": "Dark"
},
"settings.display.theme.description": {
"defaultMessage": "Select your preferred color theme for Modrinth on this device."
},
"settings.display.theme.light": {
"defaultMessage": "Light"
},
"settings.display.theme.oled": {
"defaultMessage": "OLED"
},
"settings.display.theme.preferred-dark-theme": {
"defaultMessage": "Preferred dark theme"
},
"settings.display.theme.preferred-light-theme": {
"defaultMessage": "Preferred light theme"
},
"settings.display.theme.retro": {
"defaultMessage": "Retro"
},
"settings.display.theme.system": {
"defaultMessage": "Sync with system"
},
"settings.display.theme.title": {
"defaultMessage": "Color theme"
},
"settings.language.title": {
"defaultMessage": "Language"
},
"settings.pats.title": {
"defaultMessage": "Personal access tokens"
},
"settings.profile.title": {
"defaultMessage": "Public profile"
},
"settings.sessions.title": {
"defaultMessage": "Sessions"
},
"tooltip.date-at-time": {
"defaultMessage": "{date, date, long} at {time, time, short}"
}
}

View File

@@ -0,0 +1,195 @@
import { defineMessages } from '@vintl/vintl'
export const commonMessages = defineMessages({
allProjectType: {
id: 'project-type.all',
defaultMessage: 'All',
},
cancelButton: {
id: 'button.cancel',
defaultMessage: 'Cancel',
},
collectionsLabel: {
id: 'label.collections',
defaultMessage: 'Collections',
},
continueButton: {
id: 'button.continue',
defaultMessage: 'Continue',
},
copyIdButton: {
id: 'button.copy-id',
defaultMessage: 'Copy ID',
},
changesSavedLabel: {
id: 'label.changes-saved',
defaultMessage: 'Changes saved',
},
createAProjectButton: {
id: 'button.create-a-project',
defaultMessage: 'Create a project',
},
createdAgoLabel: {
id: 'label.created-ago',
defaultMessage: 'Created {ago}',
},
dashboardLabel: {
id: 'label.dashboard',
defaultMessage: 'Dashboard',
},
dateAtTimeTooltip: {
id: 'tooltip.date-at-time',
defaultMessage: '{date, date, long} at {time, time, short}',
},
deleteLabel: {
id: 'label.delete',
defaultMessage: 'Delete',
},
descriptionLabel: {
id: 'label.description',
defaultMessage: 'Description',
},
editButton: {
id: 'button.edit',
defaultMessage: 'Edit',
},
errorLabel: {
id: 'label.error',
defaultMessage: 'Error',
},
errorNotificationTitle: {
id: 'notification.error.title',
defaultMessage: 'An error occurred',
},
followedProjectsLabel: {
id: 'label.followed-projects',
defaultMessage: 'Followed projects',
},
galleryInputView: {
id: 'input.view.gallery',
defaultMessage: 'Gallery view',
},
gridInputView: {
id: 'input.view.grid',
defaultMessage: 'Grid view',
},
listInputView: {
id: 'input.view.list',
defaultMessage: 'Rows view',
},
moderationLabel: {
id: 'label.moderation',
defaultMessage: 'Moderation',
},
notificationsLabel: {
id: 'label.notifications',
defaultMessage: 'Notifications',
},
privateLabel: {
id: 'collection.label.private',
defaultMessage: 'Private',
},
publicLabel: {
id: 'label.public',
defaultMessage: 'Public',
},
rejectedLabel: {
id: 'label.rejected',
defaultMessage: 'Rejected',
},
reportButton: {
id: 'button.report',
defaultMessage: 'Report',
},
passwordLabel: {
id: 'label.password',
defaultMessage: 'Password',
},
saveButton: {
id: 'button.save',
defaultMessage: 'Save',
},
saveChangesButton: {
id: 'button.save-changes',
defaultMessage: 'Save changes',
},
scopesLabel: {
id: 'label.scopes',
defaultMessage: 'Scopes',
},
serversLabel: {
id: 'label.servers',
defaultMessage: 'Servers',
},
settingsLabel: {
id: 'label.settings',
defaultMessage: 'Settings',
},
signInButton: {
id: 'button.sign-in',
defaultMessage: 'Sign in',
},
signOutButton: {
id: 'button.sign-out',
defaultMessage: 'Sign out',
},
titleLabel: {
id: 'label.title',
defaultMessage: 'Title',
},
unlistedLabel: {
id: 'label.unlisted',
defaultMessage: 'Unlisted',
},
uploadImageButton: {
id: 'button.upload-image',
defaultMessage: 'Upload image',
},
visibilityLabel: {
id: 'label.visibility',
defaultMessage: 'Visibility',
},
visitYourProfile: {
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
})
export const commonSettingsMessages = defineMessages({
appearance: {
id: 'settings.appearance.title',
defaultMessage: 'Appearance',
},
language: {
id: 'settings.language.title',
defaultMessage: 'Language',
},
profile: {
id: 'settings.profile.title',
defaultMessage: 'Public profile',
},
account: {
id: 'settings.account.title',
defaultMessage: 'Account and security',
},
authorizedApps: {
id: 'settings.authorized-apps.title',
defaultMessage: 'Authorized apps',
},
sessions: {
id: 'settings.sessions.title',
defaultMessage: 'Sessions',
},
pats: {
id: 'settings.pats.title',
defaultMessage: 'Personal access tokens',
},
applications: {
id: 'settings.applications.title',
defaultMessage: 'Your applications',
},
billing: {
id: 'settings.billing.title',
defaultMessage: 'Billing and subscriptions',
},
})

View File

@@ -0,0 +1,608 @@
import { type Ref , type Component, computed, readonly, ref } from 'vue';
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
import { ClientIcon, ServerIcon } from '@modrinth/assets'
type BaseOption = {
id: string
formatted_name?: string
toggle_group?: string
icon?: string | Component,
query_value?: string,
}
export type FilterOption = BaseOption & (
{ method: 'or' | 'and', value: string, } |
{ method: 'environment', environment: 'client' | 'server', }
)
export type FilterType = {
id: string,
formatted_name: string,
options: FilterOption[],
supported_project_types: ProjectType[],
query_param: string,
supports_negative_filter: boolean
toggle_groups?: {
id: string,
formatted_name: string,
query_param?: string,
}[],
searchable: boolean,
allows_custom_options?: 'and' | 'or',
} & ({
display: 'all' | 'scrollable' | 'none'
} | {
display: 'expandable',
default_values: string[]
})
export type FilterValue = {
type: string,
option: string,
negative?: boolean,
}
export interface GameVersion {
version: string
version_type: 'release' | 'snapshot' | 'alpha' | 'beta'
date: string
major: boolean
}
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
const ALL_PROJECT_TYPES: ProjectType[] = [ 'mod', 'modpack', 'resourcepack', 'shader', 'datapack', 'plugin' ]
export interface Platform {
name: string
icon: string
supported_project_types: ProjectType[]
default: boolean
formatted_name: string
}
export interface Category {
icon: string
name: string
project_type: ProjectType
header: string
}
export interface Tags {
gameVersions: GameVersion[]
loaders: Platform[]
categories: Category[]
}
export interface SortType {
display: string
name: string
}
export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, providedFilters: Ref<FilterValue[]>) {
const query = ref('')
const maxResults = ref(20)
const sortTypes: readonly SortType[] = readonly([
{ display: 'Relevance', name: 'relevance' },
{ display: 'Downloads', name: 'downloads' },
{ display: 'Followers', name: 'follows' },
{ display: 'Date published', name: 'newest' },
{ display: 'Date updated', name: 'updated' },
])
const currentSortType: Ref<SortType> = ref({ name: 'relevance', display: 'Relevance' })
const route = useRoute()
const currentPage = ref(1)
const currentFilters: Ref<FilterValue[]> = ref<FilterValue[]>([])
const toggledGroups = ref<string[]>([])
const overriddenProvidedFilterTypes = ref<string[]>([])
const { formatMessage } = useVIntl();
const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const category of sortByNameOrNumber(tags.value.categories.slice(), ['header', 'name'])) {
const filterTypeId = `category_${category.project_type}_${category.header}`
if (!categoryFilters[filterTypeId]) {
categoryFilters[filterTypeId] = {
id: filterTypeId,
formatted_name: formatCategoryHeader(category.header),
supported_project_types: category.project_type === 'mod' ? ['mod', 'plugin', 'datapack'] : [category.project_type],
display: 'all',
query_param: category.header === 'resolutions' ? 'g' : 'f',
supports_negative_filter: true,
searchable: false,
options: []
}
}
categoryFilters[filterTypeId].options.push({
id: category.name,
formatted_name: formatCategory(category.name),
icon: category.icon,
value: `categories:${category.name}`,
method: category.header === 'resolutions' ? 'or' : 'and'
})
}
const filterTypes: FilterType[] = [
...Object.values(categoryFilters),
{
id: 'environment',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment', defaultMessage: 'Environment' })),
supported_project_types: [ 'mod', 'modpack' ],
display: 'all',
query_param: 'e',
supports_negative_filter: false,
searchable: false,
options: [
{
id: 'client',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.client', defaultMessage: 'Client' })),
icon: ClientIcon,
method: 'environment',
environment: 'client',
},
{
id: 'server',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.server', defaultMessage: 'Server' })),
icon: ServerIcon,
method: 'environment',
environment: 'server',
}
]
},
{
id: 'game_version',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version', defaultMessage: 'Game version' })),
supported_project_types: ALL_PROJECT_TYPES,
display: 'scrollable',
query_param: 'v',
supports_negative_filter: false,
toggle_groups: [
{
id: 'all_versions',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version.all_versions', defaultMessage: 'Show all versions' })),
query_param: 'h'
}
],
searchable: true,
options: tags.value.gameVersions.map(gameVersion =>
({
id: gameVersion.version,
toggle_group: gameVersion.version_type !== 'release' ? 'all_versions' : undefined,
value: `versions:${gameVersion.version}`,
query_value: gameVersion.version,
method: 'or'
})),
},
{
id: 'mod_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.mod_loader', defaultMessage: 'Loader' })),
supported_project_types: [ 'mod' ],
display: 'expandable',
query_param: 'g',
supports_negative_filter: true,
default_values: [ 'fabric', 'forge', 'neoforge', 'quilt' ],
searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('mod') && !loader.supported_project_types.includes('plugin') && !loader.supported_project_types.includes('datapack')).map(loader => {
return {
id: loader.name,
formatted_name: formatCategory(loader.name),
icon: loader.icon,
method: 'or',
value: `categories:${loader.name}`,
}
}),
},
{
id: 'modpack_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.modpack_loader', defaultMessage: 'Loader' })),
supported_project_types: [ 'modpack' ],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('modpack')).map(loader => {
return {
id: loader.name,
formatted_name: formatCategory(loader.name),
icon: loader.icon,
method: 'or',
value: `categories:${loader.name}`,
}
}),
},
{
id: 'plugin_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_loader', defaultMessage: 'Loader' })),
supported_project_types: [ 'plugin' ],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('plugin') && !['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => {
return {
id: loader.name,
formatted_name: formatCategory(loader.name),
icon: loader.icon,
method: 'or',
value: `categories:${loader.name}`,
}
}),
},
{
id: 'plugin_platform',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_platform', defaultMessage: 'Platform' })),
supported_project_types: [ 'plugin' ],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
searchable: false,
options: tags.value.loaders.filter((loader) => ['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => {
return {
id: loader.name,
formatted_name: formatCategory(loader.name),
icon: loader.icon,
method: 'or',
value: `categories:${loader.name}`,
}
}),
},
{
id: 'shader_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.shader_loader', defaultMessage: 'Loader' })),
supported_project_types: [ 'shader' ],
display: 'all',
query_param: 'g',
supports_negative_filter: true,
searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('shader')).map(loader => {
return {
id: loader.name,
formatted_name: formatCategory(loader.name),
icon: loader.icon,
method: 'or',
value: `categories:${loader.name}`,
}
}),
},
{
id: 'license',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license', defaultMessage: 'License' })),
supported_project_types: [ 'mod', 'modpack', 'resourcepack', 'shader', 'plugin', 'datapack' ],
query_param: 'l',
supports_negative_filter: true,
display: 'all',
searchable: false,
options: [
{
id: 'open_source',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license.open_source', defaultMessage: 'Open source' })),
method: 'and',
value: 'open_source:true',
},
]
},
{
id: 'project_id',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.project_id', defaultMessage: 'Project ID' })),
supported_project_types: ALL_PROJECT_TYPES,
query_param: 'pid',
supports_negative_filter: true,
display: 'none',
searchable: false,
options: [],
allows_custom_options: 'and'
}
]
return filterTypes.filter(filterType => filterType.supported_project_types.some(projectType => projectTypes.value.includes(projectType)))
})
const facets = computed(() => {
const validProvidedFilters = providedFilters.value.filter(providedFilter => !overriddenProvidedFilterTypes.value.includes(providedFilter.type))
const filteredFilters = currentFilters.value.filter((userFilter) => !validProvidedFilters.some(providedFilter => providedFilter.type === userFilter.type))
const filterValues = [...filteredFilters, ...validProvidedFilters]
const andFacets: string[][] = [];
const orFacets: Record<string, string[]> = {};
for (const filterValue of filterValues) {
const type = filters.value.find(type => type.id === filterValue.type)
if (!type) {
console.error(`Filter type ${filterValue.type} not found`)
continue
}
let option = type?.options.find(option => option.id === filterValue.option)
if (!option && type.allows_custom_options) {
option = {
id: filterValue.option,
formatted_name: filterValue.option,
icon: undefined,
method: type.allows_custom_options,
value: filterValue.option,
}
} else if (!option) {
console.error(`Filter option ${filterValue.option} not found`)
continue
}
if (option.method === 'or' || option.method === 'and') {
if (filterValue.negative) {
andFacets.push([option.value.replace(':', '!=')]);
} else {
if (option.method === 'or') {
if (!orFacets[type.id]) {
orFacets[type.id] = []
}
orFacets[type.id].push(option.value);
} else if (option.method === 'and') {
andFacets.push([option.value]);
}
}
}
}
Object.values(orFacets).forEach((facets) => andFacets.push(facets))
/*
Add environment facets, separate from the rest because it oddly depends on the combination
of filters selected to determine which facets to add.
*/
const client = currentFilters.value
.some((filter) => filter.type === 'environment' && filter.option === 'client')
const server = currentFilters.value
.some((filter) => filter.type === 'environment' && filter.option === 'server')
andFacets.push(...createEnvironmentFacets(client, server))
const projectType = projectTypes.value.map((projectType) => `project_type:${projectType}`)
if (andFacets.length > 0) {
return [projectType, ...andFacets]
} else {
return [projectType]
}
})
const requestParams: Ref<string> = computed(() => {
const params = [`limit=${maxResults.value}`, `index=${currentSortType.value.name}`]
if (query.value.length > 0) {
params.push(`query=${encodeURIComponent(query.value)}`);
}
params.push(`facets=${encodeURIComponent(JSON.stringify(facets.value))}`);
const offset = (currentPage.value - 1) * maxResults.value;
if (currentPage.value !== 1) {
params.push(`offset=${offset}`);
}
return `?${params.join('&')}`;
})
readQueryParams();
function readQueryParams() {
const readParams = new Set<string>();
// Load legacy params
loadQueryParam(['l'], (openSource) => {
if (openSource === 'true' && !currentFilters.value.some(filter => filter.type === 'license' && filter.option === 'open_source')) {
currentFilters.value.push({
type: 'license',
option: 'open_source',
negative: false,
});
readParams.add('l');
}
});
loadQueryParam(['nf'], (filter) => {
const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter)
typesLoop: for (const type of filters.value) {
for (const option of type.options) {
const value = getOptionValue(option, false);
if (set.has(value) && !currentFilters.value.some(filter => filter.type === type.id && filter.option === option.id)) {
currentFilters.value.push({
type: type.id,
option: option.id,
negative: true,
})
readParams.add(type.query_param);
set.delete(value)
if (set.size === 0) {
break typesLoop;
}
}
}
}
})
loadQueryParam(['s'], (sort) => {
currentSortType.value = sortTypes.find(sortType => sortType.name === sort) ?? sortTypes[0]
readParams.add('s');
})
loadQueryParam(['m'], (count) => {
maxResults.value = Number(count)
readParams.add('m');
})
loadQueryParam(['o'], (offset) => {
currentPage.value = Math.ceil(Number(offset) / maxResults.value) + 1
readParams.add('o');
})
loadQueryParam(['page'], (page) => {
currentPage.value = Number(page)
readParams.add('page');
})
for (const key of Object.keys(route.query).filter(key => !readParams.has(key))) {
const type = filters.value.find(type => type.query_param === key)
if (type) {
const values = getParamValuesAsArray(route.query[key])
for (const value of values) {
const negative = !value.includes(':') && value.includes('!=')
const option = type.options.find(option => (getOptionValue(option, negative)) === value)
if (!option && type.allows_custom_options) {
currentFilters.value.push({
type: type.id,
option: value.replace('!=', ':'),
negative: negative
})
} else if (option) {
currentFilters.value.push({
type: type.id,
option: option.id,
negative: negative
})
} else {
console.error(`Unknown filter option: ${value}`)
}
}
} else {
console.error(`Unknown filter type: ${key}`)
}
}
}
function createPageParams(): LocationQueryRaw {
const items: Record<string, string[]> = {};
if (query.value) {
items.q = [query.value];
}
currentFilters.value.forEach(filterValue => {
const type = filters.value.find(type => type.id === filterValue.type)
const option = type?.options.find((option) => option.id === filterValue.option)
if (type && option) {
const value = getOptionValue(option, filterValue.negative);
if (items[type.query_param]) {
items[type.query_param].push(value)
} else {
items[type.query_param] = [value]
}
}
})
toggledGroups.value.forEach(groupId => {
const group = filters.value
.flatMap(filter => filter.toggle_groups)
.find(group => group && group.id === groupId)
if (group && 'query_param' in group && group.query_param) {
items[group.query_param] = [String(true)]
}
})
if (currentSortType.value.name !== "relevance") {
items.s = [currentSortType.value.name];
}
if (maxResults.value !== 20) {
items.m = [String(maxResults.value)];
}
if (currentPage.value > 1) {
items.page = [String(currentPage.value)];
}
return items;
}
function createPageParamsString(pageParams: Record<string, string | string[] | boolean | number>) {
let url = ``;
Object.entries(pageParams).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(value => {
url = addQueryParam(url, key, value)
})
} else {
url = addQueryParam(url, key, value)
}
})
return url;
}
function loadQueryParam(params: string[], provider: ((param: LocationQueryValue | LocationQueryValue[]) => void)) {
for (const param of params) {
if (param in route.query) {
provider(route.query[param]);
return;
}
}
}
return {
// Selections
query,
currentSortType,
currentFilters,
toggledGroups,
currentPage,
maxResults,
overriddenProvidedFilterTypes,
// Lists
sortTypes,
filters,
// Computed
facets,
requestParams,
// Functions
createPageParams,
createPageParamsString,
}
}
export function createEnvironmentFacets(client: boolean, server: boolean): string[][] {
const facets: string[][] = [];
if (client && server) {
facets.push(["client_side:required"], ["server_side:required"])
} else if (client) {
facets.push(
["client_side:optional", "client_side:required"],
["server_side:optional", "server_side:unsupported"]
);
} else if (server) {
facets.push(
["client_side:optional", "client_side:unsupported"],
["server_side:optional", "server_side:required"]
);
}
return facets;
}
function getOptionValue(option: FilterOption, negative?: boolean): string {
let value = option.method === 'or' || option.method === 'and' ? option.value : option.id
if (negative === true) {
value = value.replace(':', '!=')
}
if (option.query_value) {
value = option.query_value
}
return value
}
function addQueryParam(existing: string, key: string, value: string | number | boolean) {
return existing + `${!existing ? '?' : '&'}${key}=${encodeURIComponent(value)}`
}
function getParamValuesAsArray(x: LocationQueryValue | LocationQueryValue[]): string[] {
if (x === null) {
return []
} else if (typeof x === 'string') {
return [x]
} else {
return x.filter(x => x !== null)
}
}

View File

@@ -1,5 +1,5 @@
declare module '*.vue' {
import { defineComponent } from 'vue'
import type { defineComponent } from 'vue'
const component: ReturnType<typeof defineComponent>
export default component