You've already forked AstralRinth
forked from didirus/AstralRinth
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:
91
packages/ui/src/components/base/Accordion.vue
Normal file
91
packages/ui/src/components/base/Accordion.vue
Normal 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>
|
||||
21
packages/ui/src/components/base/AutoLink.vue
Normal file
21
packages/ui/src/components/base/AutoLink.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
113
packages/ui/src/components/base/LoadingIndicator.vue
Normal file
113
packages/ui/src/components/base/LoadingIndicator.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
23
packages/ui/src/components/base/PreviewSelectButton.vue
Normal file
23
packages/ui/src/components/base/PreviewSelectButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
packages/ui/src/components/base/SimpleBadge.vue
Normal file
18
packages/ui/src/components/base/SimpleBadge.vue
Normal 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>
|
||||
20
packages/ui/src/components/base/TagItem.vue
Normal file
20
packages/ui/src/components/base/TagItem.vue
Normal 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>
|
||||
@@ -942,7 +942,7 @@ async function submitPayment() {
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
// eslint-disable-next-line no-undef
|
||||
|
||||
stripe = Stripe(props.publishableKey)
|
||||
|
||||
selectedPlan.value = 'yearly'
|
||||
|
||||
91
packages/ui/src/components/content/ContentListItem.vue
Normal file
91
packages/ui/src/components/content/ContentListItem.vue
Normal 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>
|
||||
102
packages/ui/src/components/content/ContentListPanel.vue
Normal file
102
packages/ui/src/components/content/ContentListPanel.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
85
packages/ui/src/components/project/NewProjectCard.vue
Normal file
85
packages/ui/src/components/project/NewProjectCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
74
packages/ui/src/components/project/ProjectHeader.vue
Normal file
74
packages/ui/src/components/project/ProjectHeader.vue
Normal 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>
|
||||
@@ -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>
|
||||
289
packages/ui/src/components/project/ProjectPageVersions.vue
Normal file
289
packages/ui/src/components/project/ProjectPageVersions.vue
Normal 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>
|
||||
@@ -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>
|
||||
118
packages/ui/src/components/project/ProjectSidebarCreators.vue
Normal file
118
packages/ui/src/components/project/ProjectSidebarCreators.vue
Normal 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>
|
||||
142
packages/ui/src/components/project/ProjectSidebarDetails.vue
Normal file
142
packages/ui/src/components/project/ProjectSidebarDetails.vue
Normal 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>
|
||||
173
packages/ui/src/components/project/ProjectSidebarLinks.vue
Normal file
173
packages/ui/src/components/project/ProjectSidebarLinks.vue
Normal 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>
|
||||
105
packages/ui/src/components/project/ProjectStatusBadge.vue
Normal file
105
packages/ui/src/components/project/ProjectStatusBadge.vue
Normal 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>
|
||||
105
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal file
105
packages/ui/src/components/search/BrowseFiltersPanel.vue
Normal 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>
|
||||
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal file
97
packages/ui/src/components/search/SearchFilterControl.vue
Normal 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>
|
||||
59
packages/ui/src/components/search/SearchFilterOption.vue
Normal file
59
packages/ui/src/components/search/SearchFilterOption.vue
Normal 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>
|
||||
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal file
334
packages/ui/src/components/search/SearchSidebarFilter.vue
Normal 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>
|
||||
145
packages/ui/src/components/settings/ThemeSelector.vue
Normal file
145
packages/ui/src/components/settings/ThemeSelector.vue
Normal 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>
|
||||
@@ -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
|
||||
}>(),
|
||||
{
|
||||
|
||||
212
packages/ui/src/components/version/VersionFilterControl.vue
Normal file
212
packages/ui/src/components/version/VersionFilterControl.vue
Normal 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>
|
||||
47
packages/ui/src/components/version/VersionSummary.vue
Normal file
47
packages/ui/src/components/version/VersionSummary.vue
Normal 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>
|
||||
Reference in New Issue
Block a user