You've already forked AstralRinth
forked from didirus/AstralRinth
refactor: migrate to common eslint+prettier configs (#4168)
* refactor: migrate to common eslint+prettier configs * fix: prettier frontend * feat: config changes * fix: lint issues * fix: lint * fix: type imports * fix: cyclical import issue * fix: lockfile * fix: missing dep * fix: switch to tabs * fix: continue switch to tabs * fix: rustfmt parity * fix: moderation lint issue * fix: lint issues * fix: ui intl * fix: lint issues * Revert "fix: rustfmt parity" This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711. * feat: revert last rs
This commit is contained in:
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0"
|
||||
: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>
|
||||
<div v-bind="$attrs">
|
||||
<button
|
||||
v-if="!!slots.title"
|
||||
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
|
||||
@click="() => (isOpen ? close() : open())"
|
||||
>
|
||||
<slot name="button" :open="isOpen">
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<slot name="title" />
|
||||
<DropdownIcon
|
||||
class="ml-auto size-5 transition-transform duration-300 shrink-0"
|
||||
: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">
|
||||
@@ -31,20 +31,20 @@ 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,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
},
|
||||
defineProps<{
|
||||
openByDefault?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
titleWrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
type: 'standard',
|
||||
openByDefault: false,
|
||||
buttonClass: null,
|
||||
contentClass: null,
|
||||
titleWrapperClass: null,
|
||||
},
|
||||
)
|
||||
|
||||
const isOpen = ref(props.openByDefault)
|
||||
@@ -53,42 +53,42 @@ const emit = defineEmits(['onOpen', 'onClose'])
|
||||
const slots = useSlots()
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
emit('onOpen')
|
||||
isOpen.value = true
|
||||
emit('onOpen')
|
||||
}
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
emit('onClose')
|
||||
isOpen.value = false
|
||||
emit('onClose')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
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 {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="icons[type]"
|
||||
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold flex justify-between gap-4">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
<div class="font-normal">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto w-fit">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<component
|
||||
:is="icons[type]"
|
||||
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold flex justify-between gap-4">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
<div class="font-normal">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto w-fit">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String as () => 'info' | 'warning' | 'critical',
|
||||
default: 'info',
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String as () => 'info' | 'warning' | 'critical',
|
||||
default: 'info',
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const typeClasses = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
info: 'text-brand-blue',
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: InfoIcon,
|
||||
warning: IssuesIcon,
|
||||
critical: XCircleIcon,
|
||||
info: InfoIcon,
|
||||
warning: IssuesIcon,
|
||||
critical: XCircleIcon,
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<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>
|
||||
<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<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
to: any
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="src"
|
||||
ref="img"
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:loading="loading"
|
||||
@load="updatePixelated"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
}"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 104 104"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill="none" d="M0 0h103.4v103.4H0z" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#9a9a9a"
|
||||
stroke-width="5"
|
||||
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
|
||||
/>
|
||||
</svg>
|
||||
<img
|
||||
v-if="src"
|
||||
ref="img"
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
pixelated: pixelated,
|
||||
}"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:loading="loading"
|
||||
@load="updatePixelated"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
circle: circle,
|
||||
'no-shadow': noShadow,
|
||||
raised: raised,
|
||||
}"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 104 104"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill="none" d="M0 0h103.4v103.4H0z" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="#9a9a9a"
|
||||
stroke-width="5"
|
||||
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -51,110 +51,110 @@ const pixelated = ref(false)
|
||||
const img = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '2rem',
|
||||
},
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noShadow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
default: 'eager',
|
||||
},
|
||||
raised: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tintBy: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '2rem',
|
||||
},
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noShadow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
default: 'eager',
|
||||
},
|
||||
raised: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tintBy: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const LEGACY_PRESETS = {
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
xxs: '1.25rem',
|
||||
xs: '2.5rem',
|
||||
sm: '3rem',
|
||||
md: '6rem',
|
||||
lg: '9rem',
|
||||
}
|
||||
|
||||
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
|
||||
|
||||
function updatePixelated() {
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
} else {
|
||||
pixelated.value = false
|
||||
}
|
||||
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
|
||||
pixelated.value = true
|
||||
} else {
|
||||
pixelated.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const tint = computed(() => {
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
if (props.tintBy) {
|
||||
return hash(props.tintBy) % 360
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function hash(str) {
|
||||
let hash = 0
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
const chr = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
let hash = 0
|
||||
for (let i = 0, len = str.length; i < len; i++) {
|
||||
const chr = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + chr
|
||||
hash |= 0
|
||||
}
|
||||
return hash
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar {
|
||||
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
|
||||
--_size: 2rem;
|
||||
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
|
||||
--_size: 2rem;
|
||||
|
||||
border: 1px solid var(--color-button-border);
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_size));
|
||||
position: relative;
|
||||
border: 1px solid var(--color-button-border);
|
||||
background-color: var(--color-button-bg);
|
||||
object-fit: contain;
|
||||
border-radius: calc(16 / 96 * var(--_size));
|
||||
position: relative;
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
&:not(.no-shadow) {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
&.no-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
&.pixelated {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
&.raised {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
&.tint {
|
||||
background-color: color-mix(in oklch, var(--color-button-bg) 100%, var(--_tint) 5%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,247 +1,247 @@
|
||||
<template>
|
||||
<span :class="'version-badge ' + color + ' type--' + type">
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
<span :class="'version-badge ' + color + ' type--' + type">
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'">
|
||||
<ModrinthIcon aria-hidden="true" /> {{ formatMessage(messages.modrinthTeamLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator'">
|
||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.moderatorLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'creator'">
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.creatorLabel) }}
|
||||
</template>
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'">
|
||||
<ModrinthIcon aria-hidden="true" /> {{ formatMessage(messages.modrinthTeamLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'moderator'">
|
||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.moderatorLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'creator'">
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.creatorLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'">
|
||||
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'approved-general'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'unlisted'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'private'">
|
||||
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'scheduled'">
|
||||
<CalendarIcon aria-hidden="true" /> {{ formatMessage(messages.scheduledLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'draft'">
|
||||
<FileTextIcon aria-hidden="true" /> {{ formatMessage(messages.draftLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'archived'">
|
||||
<ArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.archivedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'rejected'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.rejectedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'processing'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.underReviewLabel) }}
|
||||
</template>
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'">
|
||||
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'approved-general'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'unlisted'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'private'">
|
||||
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'scheduled'">
|
||||
<CalendarIcon aria-hidden="true" /> {{ formatMessage(messages.scheduledLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'draft'">
|
||||
<FileTextIcon aria-hidden="true" /> {{ formatMessage(messages.draftLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'archived'">
|
||||
<ArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.archivedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'rejected'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.rejectedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'processing'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.underReviewLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Team members -->
|
||||
<template v-else-if="type === 'accepted'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.acceptedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'pending'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.pendingLabel) }}
|
||||
</template>
|
||||
<!-- Team members -->
|
||||
<template v-else-if="type === 'accepted'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.acceptedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'pending'">
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(messages.pendingLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Transaction statuses (pending, processing reused) -->
|
||||
<template v-else-if="type === 'processed'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.processedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'failed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.failedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'returned'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.returnedLabel) }}
|
||||
</template>
|
||||
<!-- Transaction statuses (pending, processing reused) -->
|
||||
<template v-else-if="type === 'processed'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.processedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'failed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.failedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'returned'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.returnedLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Report status -->
|
||||
<template v-else-if="type === 'closed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
|
||||
</template>
|
||||
<!-- Report status -->
|
||||
<template v-else-if="type === 'closed'">
|
||||
<XIcon aria-hidden="true" /> {{ formatMessage(messages.closedLabel) }}
|
||||
</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
BoxIcon,
|
||||
ListIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
XIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon,
|
||||
CheckIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
ArchiveIcon,
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
LockIcon,
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const messages = defineMessages({
|
||||
acceptedLabel: {
|
||||
id: 'omorphia.component.badge.label.accepted',
|
||||
defaultMessage: 'Accepted',
|
||||
},
|
||||
approvedLabel: {
|
||||
id: 'omorphia.component.badge.label.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archivedLabel: {
|
||||
id: 'omorphia.component.badge.label.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
closedLabel: {
|
||||
id: 'omorphia.component.badge.label.closed',
|
||||
defaultMessage: 'Closed',
|
||||
},
|
||||
creatorLabel: {
|
||||
id: 'omorphia.component.badge.label.creator',
|
||||
defaultMessage: 'Creator',
|
||||
},
|
||||
draftLabel: {
|
||||
id: 'omorphia.component.badge.label.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
failedLabel: {
|
||||
id: 'omorphia.component.badge.label.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
listedLabel: {
|
||||
id: 'omorphia.component.badge.label.listed',
|
||||
defaultMessage: 'Listed',
|
||||
},
|
||||
moderatorLabel: {
|
||||
id: 'omorphia.component.badge.label.moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
modrinthTeamLabel: {
|
||||
id: 'omorphia.component.badge.label.modrinth-team',
|
||||
defaultMessage: 'Modrinth Team',
|
||||
},
|
||||
pendingLabel: {
|
||||
id: 'omorphia.component.badge.label.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
privateLabel: {
|
||||
id: 'omorphia.component.badge.label.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
processedLabel: {
|
||||
id: 'omorphia.component.badge.label.processed',
|
||||
defaultMessage: 'Processed',
|
||||
},
|
||||
rejectedLabel: {
|
||||
id: 'omorphia.component.badge.label.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
returnedLabel: {
|
||||
id: 'omorphia.component.badge.label.returned',
|
||||
defaultMessage: 'Returned',
|
||||
},
|
||||
scheduledLabel: {
|
||||
id: 'omorphia.component.badge.label.scheduled',
|
||||
defaultMessage: 'Scheduled',
|
||||
},
|
||||
underReviewLabel: {
|
||||
id: 'omorphia.component.badge.label.under-review',
|
||||
defaultMessage: 'Under review',
|
||||
},
|
||||
unlistedLabel: {
|
||||
id: 'omorphia.component.badge.label.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
acceptedLabel: {
|
||||
id: 'omorphia.component.badge.label.accepted',
|
||||
defaultMessage: 'Accepted',
|
||||
},
|
||||
approvedLabel: {
|
||||
id: 'omorphia.component.badge.label.approved',
|
||||
defaultMessage: 'Approved',
|
||||
},
|
||||
archivedLabel: {
|
||||
id: 'omorphia.component.badge.label.archived',
|
||||
defaultMessage: 'Archived',
|
||||
},
|
||||
closedLabel: {
|
||||
id: 'omorphia.component.badge.label.closed',
|
||||
defaultMessage: 'Closed',
|
||||
},
|
||||
creatorLabel: {
|
||||
id: 'omorphia.component.badge.label.creator',
|
||||
defaultMessage: 'Creator',
|
||||
},
|
||||
draftLabel: {
|
||||
id: 'omorphia.component.badge.label.draft',
|
||||
defaultMessage: 'Draft',
|
||||
},
|
||||
failedLabel: {
|
||||
id: 'omorphia.component.badge.label.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
listedLabel: {
|
||||
id: 'omorphia.component.badge.label.listed',
|
||||
defaultMessage: 'Listed',
|
||||
},
|
||||
moderatorLabel: {
|
||||
id: 'omorphia.component.badge.label.moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
modrinthTeamLabel: {
|
||||
id: 'omorphia.component.badge.label.modrinth-team',
|
||||
defaultMessage: 'Modrinth Team',
|
||||
},
|
||||
pendingLabel: {
|
||||
id: 'omorphia.component.badge.label.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
privateLabel: {
|
||||
id: 'omorphia.component.badge.label.private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
processedLabel: {
|
||||
id: 'omorphia.component.badge.label.processed',
|
||||
defaultMessage: 'Processed',
|
||||
},
|
||||
rejectedLabel: {
|
||||
id: 'omorphia.component.badge.label.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
},
|
||||
returnedLabel: {
|
||||
id: 'omorphia.component.badge.label.returned',
|
||||
defaultMessage: 'Returned',
|
||||
},
|
||||
scheduledLabel: {
|
||||
id: 'omorphia.component.badge.label.scheduled',
|
||||
defaultMessage: 'Scheduled',
|
||||
},
|
||||
underReviewLabel: {
|
||||
id: 'omorphia.component.badge.label.under-review',
|
||||
defaultMessage: 'Under review',
|
||||
},
|
||||
unlistedLabel: {
|
||||
id: 'omorphia.component.badge.label.unlisted',
|
||||
defaultMessage: 'Unlisted',
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
type: string
|
||||
color?: string
|
||||
type: string
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.version-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
--badge-color: var(--color-gray);
|
||||
color: var(--badge-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
--badge-color: var(--color-gray);
|
||||
color: var(--badge-color);
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
background-color: var(--badge-color);
|
||||
}
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
background-color: var(--badge-color);
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.type--returned,
|
||||
&.type--failed,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.type--returned,
|
||||
&.type--failed,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--processed,
|
||||
&.type--approved-general,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--processed,
|
||||
&.type--approved-general,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
|
||||
&.type--creator,
|
||||
&.type--approved,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
&.type--creator,
|
||||
&.type--approved,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
|
||||
&.type--unlisted,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
&.type--unlisted,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
|
||||
&.type--private,
|
||||
&.gray {
|
||||
--badge-color: var(--color-gray);
|
||||
}
|
||||
&.type--private,
|
||||
&.gray {
|
||||
--badge-color: var(--color-gray);
|
||||
}
|
||||
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,122 +3,122 @@ import { ExternalIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transparent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilledOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transparent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hoverFilledOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const accentedButton = computed(() =>
|
||||
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color),
|
||||
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color),
|
||||
)
|
||||
|
||||
const classes = computed(() => {
|
||||
const color = props.color
|
||||
return {
|
||||
'icon-only': props.iconOnly,
|
||||
'btn-large': props.large,
|
||||
'btn-danger': color === 'danger',
|
||||
'btn-primary': color === 'primary',
|
||||
'btn-secondary': color === 'secondary',
|
||||
'btn-highlight': color === 'highlight',
|
||||
'btn-red': color === 'red',
|
||||
'btn-orange': color === 'orange',
|
||||
'btn-green': color === 'green',
|
||||
'btn-blue': color === 'blue',
|
||||
'btn-purple': color === 'purple',
|
||||
'btn-gray': color === 'gray',
|
||||
'btn-transparent': props.transparent,
|
||||
'btn-hover-filled': props.hoverFilled,
|
||||
'btn-hover-filled-only': props.hoverFilledOnly,
|
||||
'btn-outline': props.outline,
|
||||
'color-accent-contrast': accentedButton,
|
||||
}
|
||||
const color = props.color
|
||||
return {
|
||||
'icon-only': props.iconOnly,
|
||||
'btn-large': props.large,
|
||||
'btn-danger': color === 'danger',
|
||||
'btn-primary': color === 'primary',
|
||||
'btn-secondary': color === 'secondary',
|
||||
'btn-highlight': color === 'highlight',
|
||||
'btn-red': color === 'red',
|
||||
'btn-orange': color === 'orange',
|
||||
'btn-green': color === 'green',
|
||||
'btn-blue': color === 'blue',
|
||||
'btn-purple': color === 'purple',
|
||||
'btn-gray': color === 'gray',
|
||||
'btn-transparent': props.transparent,
|
||||
'btn-hover-filled': props.hoverFilled,
|
||||
'btn-hover-filled-only': props.hoverFilledOnly,
|
||||
'btn-outline': props.outline,
|
||||
'color-accent-contrast': accentedButton,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="link"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</a>
|
||||
<button v-else class="btn" :class="classes" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
<router-link
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</router-link>
|
||||
<a
|
||||
v-else-if="link"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</a>
|
||||
<button v-else class="btn" :class="classes" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:where(button) {
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
background: none;
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,230 +2,230 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
colorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
color: 'standard',
|
||||
size: 'standard',
|
||||
circular: false,
|
||||
type: 'standard',
|
||||
colorFill: 'auto',
|
||||
hoverColorFill: 'auto',
|
||||
highlightedStyle: 'main-nav-primary',
|
||||
highlighted: false,
|
||||
},
|
||||
defineProps<{
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
circular?: boolean
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
|
||||
colorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
|
||||
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
color: 'standard',
|
||||
size: 'standard',
|
||||
circular: false,
|
||||
type: 'standard',
|
||||
colorFill: 'auto',
|
||||
hoverColorFill: 'auto',
|
||||
highlightedStyle: 'main-nav-primary',
|
||||
highlighted: false,
|
||||
},
|
||||
)
|
||||
|
||||
const highlightedColorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand-highlight)'
|
||||
case 'red':
|
||||
return 'var(--color-red-highlight)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange-highlight)'
|
||||
case 'green':
|
||||
return 'var(--color-green-highlight)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue-highlight)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple-highlight)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand-highlight)'
|
||||
case 'red':
|
||||
return 'var(--color-red-highlight)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange-highlight)'
|
||||
case 'green':
|
||||
return 'var(--color-green-highlight)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue-highlight)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple-highlight)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const colorVar = computed(() => {
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand)'
|
||||
case 'red':
|
||||
return 'var(--color-red)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange)'
|
||||
case 'green':
|
||||
return 'var(--color-green)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
switch (props.color) {
|
||||
case 'brand':
|
||||
return 'var(--color-brand)'
|
||||
case 'red':
|
||||
return 'var(--color-red)'
|
||||
case 'orange':
|
||||
return 'var(--color-orange)'
|
||||
case 'green':
|
||||
return 'var(--color-green)'
|
||||
case 'blue':
|
||||
return 'var(--color-blue)'
|
||||
case 'purple':
|
||||
return 'var(--color-purple)'
|
||||
case 'standard':
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const height = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '3rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1.5rem'
|
||||
}
|
||||
return '2.25rem'
|
||||
if (props.size === 'large') {
|
||||
return '3rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1.5rem'
|
||||
}
|
||||
return '2.25rem'
|
||||
})
|
||||
|
||||
const width = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return props.circular ? '3rem' : 'auto'
|
||||
} else if (props.size === 'small') {
|
||||
return props.circular ? '1.5rem' : 'auto'
|
||||
}
|
||||
return props.circular ? '2.25rem' : 'auto'
|
||||
if (props.size === 'large') {
|
||||
return props.circular ? '3rem' : 'auto'
|
||||
} else if (props.size === 'small') {
|
||||
return props.circular ? '1.5rem' : 'auto'
|
||||
}
|
||||
return props.circular ? '2.25rem' : 'auto'
|
||||
})
|
||||
|
||||
const paddingX = computed(() => {
|
||||
let padding = props.circular ? '0.5rem' : '0.75rem'
|
||||
if (props.size === 'large') {
|
||||
padding = props.circular ? '0.75rem' : '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
padding = props.circular ? '0.125rem' : '0.5rem'
|
||||
}
|
||||
return `calc(${padding} - 0.125rem)`
|
||||
let padding = props.circular ? '0.5rem' : '0.75rem'
|
||||
if (props.size === 'large') {
|
||||
padding = props.circular ? '0.75rem' : '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
padding = props.circular ? '0.125rem' : '0.5rem'
|
||||
}
|
||||
return `calc(${padding} - 0.125rem)`
|
||||
})
|
||||
|
||||
const paddingY = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '0.75rem'
|
||||
}
|
||||
return '0.5rem'
|
||||
if (props.size === 'large') {
|
||||
return '0.75rem'
|
||||
}
|
||||
return '0.5rem'
|
||||
})
|
||||
|
||||
const gap = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '0.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.25rem'
|
||||
}
|
||||
return '0.375rem'
|
||||
if (props.size === 'large') {
|
||||
return '0.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.25rem'
|
||||
}
|
||||
return '0.375rem'
|
||||
})
|
||||
|
||||
const fontWeight = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '800'
|
||||
}
|
||||
return '600'
|
||||
if (props.size === 'large') {
|
||||
return '800'
|
||||
}
|
||||
return '600'
|
||||
})
|
||||
|
||||
const radius = computed(() => {
|
||||
if (props.circular) {
|
||||
return '99999px'
|
||||
}
|
||||
if (props.circular) {
|
||||
return '99999px'
|
||||
}
|
||||
|
||||
if (props.size === 'large') {
|
||||
return '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.5rem'
|
||||
}
|
||||
return '0.75rem'
|
||||
if (props.size === 'large') {
|
||||
return '1rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '0.5rem'
|
||||
}
|
||||
return '0.75rem'
|
||||
})
|
||||
|
||||
const iconSize = computed(() => {
|
||||
if (props.size === 'large') {
|
||||
return '1.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1rem'
|
||||
}
|
||||
return '1.25rem'
|
||||
if (props.size === 'large') {
|
||||
return '1.5rem'
|
||||
} else if (props.size === 'small') {
|
||||
return '1rem'
|
||||
}
|
||||
return '1.25rem'
|
||||
})
|
||||
|
||||
function setColorFill(
|
||||
colors: { bg: string; text: string },
|
||||
fill: 'background' | 'text' | 'none',
|
||||
colors: { bg: string; text: string },
|
||||
fill: 'background' | 'text' | 'none',
|
||||
): { bg: string; text: string } {
|
||||
if (colorVar.value) {
|
||||
if (fill === 'background') {
|
||||
if (props.type === 'highlight' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = 'var(--color-contrast)'
|
||||
} else if (props.type === 'highlight-colored-text' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = colorVar.value
|
||||
} else {
|
||||
colors.bg = colorVar.value
|
||||
colors.text = 'var(--color-accent-contrast)'
|
||||
}
|
||||
} else if (fill === 'text') {
|
||||
colors.text = colorVar.value
|
||||
}
|
||||
}
|
||||
return colors
|
||||
if (colorVar.value) {
|
||||
if (fill === 'background') {
|
||||
if (props.type === 'highlight' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = 'var(--color-contrast)'
|
||||
} else if (props.type === 'highlight-colored-text' && highlightedColorVar.value) {
|
||||
colors.bg = highlightedColorVar.value
|
||||
colors.text = colorVar.value
|
||||
} else {
|
||||
colors.bg = colorVar.value
|
||||
colors.text = 'var(--color-accent-contrast)'
|
||||
}
|
||||
} else if (fill === 'text') {
|
||||
colors.text = colorVar.value
|
||||
}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
|
||||
const colorVariables = computed(() => {
|
||||
if (props.highlighted) {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
: 'var(--color-button-bg)',
|
||||
text: 'var(--color-contrast)',
|
||||
icon:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
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};`
|
||||
}
|
||||
if (props.highlighted) {
|
||||
const colors = {
|
||||
bg:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand-highlight)'
|
||||
: 'var(--color-button-bg)',
|
||||
text: 'var(--color-contrast)',
|
||||
icon:
|
||||
props.highlightedStyle === 'main-nav-primary'
|
||||
? 'var(--color-brand)'
|
||||
: 'var(--color-contrast)',
|
||||
}
|
||||
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};`
|
||||
}
|
||||
|
||||
let colors = {
|
||||
bg: 'var(--color-button-bg)',
|
||||
text: 'var(--color-base)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
let colors = {
|
||||
bg: 'var(--color-button-bg)',
|
||||
text: 'var(--color-base)',
|
||||
}
|
||||
let hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
|
||||
if (props.type === 'outlined') {
|
||||
hoverColors.bg = 'transparent'
|
||||
}
|
||||
if (props.type === 'outlined') {
|
||||
hoverColors.bg = 'transparent'
|
||||
}
|
||||
|
||||
if (props.type === 'outlined' || props.type === 'transparent') {
|
||||
colors.bg = 'transparent'
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
|
||||
)
|
||||
} else {
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
|
||||
)
|
||||
}
|
||||
if (props.type === 'outlined' || props.type === 'transparent') {
|
||||
colors.bg = 'transparent'
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
|
||||
)
|
||||
} else {
|
||||
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
|
||||
hoverColors = setColorFill(
|
||||
hoverColors,
|
||||
props.hoverColorFill === 'auto' ? 'background' : props.hoverColorFill,
|
||||
)
|
||||
}
|
||||
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
|
||||
})
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
if (props.size === 'small') {
|
||||
return 'text-sm'
|
||||
}
|
||||
return 'text-base'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="[{ outline: type === 'outlined' }, fontSize]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="[{ outline: type === 'outlined' }, fontSize]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.btn-wrapper {
|
||||
display: contents;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Searches up to 4 children deep for valid button */
|
||||
@@ -234,43 +234,43 @@ const fontSize = computed(() => {
|
||||
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@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] whitespace-nowrap;
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
color 0.25s ease-in-out;
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@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] whitespace-nowrap;
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
background-color 0.25s ease-in-out,
|
||||
color 0.25s ease-in-out;
|
||||
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
svg:first-child {
|
||||
color: var(--_icon, var(--_text));
|
||||
transition: color 0.25s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
&[disabled],
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
|
||||
|
||||
&:hover svg:first-child,
|
||||
&:focus-visible svg:first-child {
|
||||
color: var(--_hover-icon, var(--_hover-text));
|
||||
}
|
||||
}
|
||||
&:hover svg:first-child,
|
||||
&:focus-visible svg:first-child {
|
||||
color: var(--_hover-icon, var(--_hover-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
|
||||
@@ -278,11 +278,11 @@ const fontSize = computed(() => {
|
||||
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper.outline
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border-current;
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border-current;
|
||||
}
|
||||
|
||||
/*noinspection CssUnresolvedCustomProperty*/
|
||||
@@ -290,45 +290,45 @@ const fontSize = computed(() => {
|
||||
.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
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child,
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child {
|
||||
min-width: var(--_icon-size, 1rem);
|
||||
min-height: var(--_icon-size, 1rem);
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child
|
||||
> svg:first-child {
|
||||
min-width: var(--_icon-size, 1rem);
|
||||
min-height: var(--_icon-size, 1rem);
|
||||
}
|
||||
|
||||
.joined-buttons {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
display: flex;
|
||||
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,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
> .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,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> :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,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
> :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,
|
||||
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* guys, I know this is nuts, I know */
|
||||
|
||||
@@ -1,61 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
defaultCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noAutoBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
collapsed: props.defaultCollapsed,
|
||||
collapsed: props.defaultCollapsed,
|
||||
})
|
||||
|
||||
function toggleCollapsed() {
|
||||
state.collapsed = !state.collapsed
|
||||
state.collapsed = !state.collapsed
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group">
|
||||
<Button :action="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
<div class="card">
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group">
|
||||
<Button :action="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
:deep(h1, h2, h3, h4) {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,145 +1,145 @@
|
||||
<template>
|
||||
<div
|
||||
class="checkbox-outer button-within"
|
||||
:class="{ disabled }"
|
||||
role="presentation"
|
||||
@click="toggle"
|
||||
>
|
||||
<button
|
||||
class="checkbox border-none"
|
||||
role="checkbox"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
|
||||
:aria-label="description"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<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 -->
|
||||
<p v-if="label" aria-hidden="true" class="checkbox-label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<slot v-else />
|
||||
</div>
|
||||
<div
|
||||
class="checkbox-outer button-within"
|
||||
:class="{ disabled }"
|
||||
role="presentation"
|
||||
@click="toggle"
|
||||
>
|
||||
<button
|
||||
class="checkbox border-none"
|
||||
role="checkbox"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
|
||||
:aria-label="description"
|
||||
:aria-checked="modelValue"
|
||||
>
|
||||
<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 -->
|
||||
<p v-if="label" aria-hidden="true" class="checkbox-label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon, MinusIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
collapsingToggleStyle?: boolean
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
collapsingToggleStyle: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
defineProps<{
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
clickEvent?: () => void
|
||||
collapsingToggleStyle?: boolean
|
||||
indeterminate?: boolean
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
clickEvent: () => {},
|
||||
collapsingToggleStyle: false,
|
||||
indeterminate: false,
|
||||
},
|
||||
)
|
||||
|
||||
function toggle() {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox-outer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
p {
|
||||
user-select: none;
|
||||
padding: 0.2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
user-select: none;
|
||||
padding: 0.2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
|
||||
padding: 0;
|
||||
margin: 0 0.5rem 0 0;
|
||||
padding: 0;
|
||||
margin: 0 0.5rem 0 0;
|
||||
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-xs);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
&.checked {
|
||||
background-color: var(--color-brand);
|
||||
&.checked {
|
||||
background-color: var(--color-brand);
|
||||
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.collapsing {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
&.collapsing {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
|
||||
svg {
|
||||
color: inherit;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
transition: transform 0.25s ease-in-out;
|
||||
svg {
|
||||
color: inherit;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
transition: transform 0.25s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.checked {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.checked {
|
||||
svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&:disabled {
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--color-base);
|
||||
color: var(--color-base);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,81 @@
|
||||
<template>
|
||||
<div class="chips">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
class="btn"
|
||||
:class="{ selected: selected === item, capitalize: capitalize }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item" />
|
||||
<span>{{ formatLabel(item) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<Button
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
class="btn"
|
||||
:class="{ selected: selected === item, capitalize: capitalize }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item" />
|
||||
<span>{{ formatLabel(item) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
formatLabel?: (item: T) => string
|
||||
neverEmpty?: boolean
|
||||
capitalize?: boolean
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
},
|
||||
defineProps<{
|
||||
items: T[]
|
||||
formatLabel?: (item: T) => string
|
||||
neverEmpty?: boolean
|
||||
capitalize?: boolean
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
},
|
||||
)
|
||||
const selected = defineModel<T | null>()
|
||||
|
||||
// If one always has to be selected, default to the first one
|
||||
if (props.items.length > 0 && props.neverEmpty && !selected.value) {
|
||||
selected.value = props.items[0]
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
|
||||
function toggleItem(item: T) {
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chips {
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.btn {
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 0.25rem solid #ea80ff;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
}
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
<template>
|
||||
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
|
||||
<div v-bind="$attrs" :inert="collapsed">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
|
||||
<div v-bind="$attrs" :inert="collapsed">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
baseClass?: string
|
||||
collapsed: boolean
|
||||
baseClass?: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
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 {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content.open {
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,98 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||
:class="{ 'max-h-32': isCollapsed }"
|
||||
>
|
||||
<div
|
||||
class="px-4 pt-4"
|
||||
:class="{
|
||||
'content-disabled pb-16': isCollapsed,
|
||||
'pb-4': !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||
:class="{ 'max-h-32': isCollapsed }"
|
||||
>
|
||||
<div
|
||||
class="px-4 pt-4"
|
||||
:class="{
|
||||
'content-disabled pb-16': isCollapsed,
|
||||
'pb-4': !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||
></div>
|
||||
<div
|
||||
v-if="isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||
></div>
|
||||
|
||||
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
|
||||
<ExpandIcon v-if="isCollapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ isCollapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
|
||||
<ExpandIcon v-if="isCollapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ isCollapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { ExpandIcon, CollapseIcon } from '@modrinth/assets'
|
||||
import { CollapseIcon, ExpandIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initiallyCollapsed?: boolean
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
}>(),
|
||||
{
|
||||
initiallyCollapsed: true,
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
},
|
||||
defineProps<{
|
||||
initiallyCollapsed?: boolean
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
}>(),
|
||||
{
|
||||
initiallyCollapsed: true,
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
},
|
||||
)
|
||||
|
||||
const isCollapsed = ref(props.initiallyCollapsed)
|
||||
|
||||
function toggleCollapsed() {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
function setCollapsed(value: boolean) {
|
||||
isCollapsed.value = value
|
||||
isCollapsed.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.content-disabled {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
:deep(*) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
:deep(*) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
:deep(button),
|
||||
:deep(input),
|
||||
:deep(textarea),
|
||||
:deep(select),
|
||||
:deep(a),
|
||||
:deep([tabindex]) {
|
||||
tabindex: -1 !important;
|
||||
}
|
||||
:deep(button),
|
||||
:deep(input),
|
||||
:deep(textarea),
|
||||
:deep(select),
|
||||
:deep(a),
|
||||
:deep([tabindex]) {
|
||||
tabindex: -1 !important;
|
||||
}
|
||||
|
||||
:deep(*:focus) {
|
||||
outline: none !important;
|
||||
}
|
||||
:deep(*:focus) {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
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" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
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" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
|
||||
<slot name="title" />
|
||||
</h1>
|
||||
<slot name="title-suffix" />
|
||||
</div>
|
||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
||||
<slot name="summary" />
|
||||
</p>
|
||||
<div v-if="$slots.stats" class="mt-auto flex flex-wrap gap-4 empty:hidden">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useVIntl, defineMessage } from '@vintl/vintl'
|
||||
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const copiedMessage = defineMessage({
|
||||
id: 'omorphia.component.copy.action.copy',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
id: 'omorphia.component.copy.action.copy',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -22,46 +22,46 @@ const props = defineProps<{ text: string }>()
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyText() {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
copied.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code {
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<div class="double-icon">
|
||||
<slot name="primary" />
|
||||
<div class="secondary">
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="double-icon">
|
||||
<slot name="primary" />
|
||||
<div class="secondary">
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.double-icon {
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
.secondary {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-card-xs);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
.secondary {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-card-xs);
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
line-height: 0;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="dropAreaRef"
|
||||
class="drop-area"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
@dragenter.prevent="allowDrag"
|
||||
@dragover.prevent="allowDrag"
|
||||
@dragleave.prevent="hideDropArea"
|
||||
/>
|
||||
</Teleport>
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="dropAreaRef"
|
||||
class="drop-area"
|
||||
@drop.stop.prevent="handleDrop"
|
||||
@dragenter.prevent="allowDrag"
|
||||
@dragover.prevent="allowDrag"
|
||||
@dragleave.prevent="hideDropArea"
|
||||
/>
|
||||
</Teleport>
|
||||
<slot />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
accept: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
},
|
||||
defineProps<{
|
||||
accept: string
|
||||
}>(),
|
||||
{
|
||||
accept: '*',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
@@ -30,71 +30,71 @@ const dropAreaRef = ref<HTMLDivElement>()
|
||||
const fileAllowed = ref(false)
|
||||
|
||||
const hideDropArea = () => {
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
hideDropArea()
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed.value) {
|
||||
emit('change', event.dataTransfer.files)
|
||||
}
|
||||
hideDropArea()
|
||||
if (event.dataTransfer && event.dataTransfer.files && fileAllowed.value) {
|
||||
emit('change', event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const allowDrag = (event: DragEvent) => {
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
props.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
fileAllowed.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
} else {
|
||||
fileAllowed.value = false
|
||||
hideDropArea()
|
||||
}
|
||||
const file = event.dataTransfer?.items[0]
|
||||
if (
|
||||
file &&
|
||||
props.accept
|
||||
.split(',')
|
||||
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
|
||||
) {
|
||||
fileAllowed.value = true
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
event.preventDefault()
|
||||
if (dropAreaRef.value) {
|
||||
dropAreaRef.value.style.visibility = 'visible'
|
||||
}
|
||||
} else {
|
||||
fileAllowed.value = false
|
||||
hideDropArea()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('dragenter', allowDrag)
|
||||
document.addEventListener('dragenter', allowDrag)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drop-area {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
visibility: hidden;
|
||||
background-color: hsla(0, 0%, 0%, 0.5);
|
||||
transition:
|
||||
visibility 0.2s ease-in-out,
|
||||
background-color 0.1s ease-in-out;
|
||||
display: flex;
|
||||
&::before {
|
||||
--indent: 4rem;
|
||||
content: ' ';
|
||||
position: relative;
|
||||
top: var(--indent);
|
||||
left: var(--indent);
|
||||
width: calc(100% - (2 * var(--indent)));
|
||||
height: calc(100% - (2 * var(--indent)));
|
||||
border-radius: 1rem;
|
||||
border: 0.25rem dashed var(--color-button-bg);
|
||||
}
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
visibility: hidden;
|
||||
background-color: hsla(0, 0%, 0%, 0.5);
|
||||
transition:
|
||||
visibility 0.2s ease-in-out,
|
||||
background-color 0.1s ease-in-out;
|
||||
display: flex;
|
||||
&::before {
|
||||
--indent: 4rem;
|
||||
content: ' ';
|
||||
position: relative;
|
||||
top: var(--indent);
|
||||
left: var(--indent);
|
||||
width: calc(100% - (2 * var(--indent)));
|
||||
height: calc(100% - (2 * var(--indent)));
|
||||
border-radius: 1rem;
|
||||
border: 0.25rem dashed var(--color-button-bg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown.enter.prevent="toggleDropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div
|
||||
class="selected"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
'render-down': dropdownVisible && !renderUp && !disabled,
|
||||
'render-up': dropdownVisible && renderUp && !disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<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 }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
:class="{ 'selected-option': selectedValue === option }"
|
||||
:aria-selected="selectedValue === option"
|
||||
class="option"
|
||||
@click="selectOption(option, index)"
|
||||
@keydown.space.prevent="selectOption(option, index)"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="option"
|
||||
:name="name"
|
||||
/>
|
||||
<label :for="`${name}-${index}`">{{ getOptionLabel(option) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown.enter.prevent="toggleDropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div
|
||||
class="selected"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
'render-down': dropdownVisible && !renderUp && !disabled,
|
||||
'render-up': dropdownVisible && renderUp && !disabled,
|
||||
}"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<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 }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
:class="{ 'selected-option': selectedValue === option }"
|
||||
:aria-selected="selectedValue === option"
|
||||
class="option"
|
||||
@click="selectOption(option, index)"
|
||||
@keydown.space.prevent="selectOption(option, index)"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="option"
|
||||
:name="name"
|
||||
/>
|
||||
<label :for="`${name}-${index}`">{{ getOptionLabel(option) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -71,46 +71,46 @@ import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
maxVisibleOptions: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
maxVisibleOptions: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
@@ -122,230 +122,230 @@ const dropdown = ref(null)
|
||||
const optionElements = ref(null)
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return getOptionLabel(selectedValue.value) ?? props.placeholder ?? 'Select an option'
|
||||
return getOptionLabel(selectedValue.value) ?? props.placeholder ?? 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed({
|
||||
get() {
|
||||
return props.modelValue || selectedValue.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
get() {
|
||||
return props.modelValue || selectedValue.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option, index) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
dropdownVisible.value = false
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const focusNextOptionOrOpen = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element) => {
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animated-dropdown {
|
||||
width: 20rem;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20rem;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
.selected {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
transition: 0.05s;
|
||||
transition: 0.05s;
|
||||
|
||||
&:not(.render-down):not(.render-up) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
&:not(.render-down):not(.render-up) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transition: transform 0.2s ease;
|
||||
.arrow {
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: v-bind('maxVisibleOptions ? `calc(${maxVisibleOptions} * 3rem)` : "18.75rem"');
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: v-bind('maxVisibleOptions ? `calc(${maxVisibleOptions} * 3rem)` : "18.75rem"');
|
||||
overflow-y: auto;
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
}
|
||||
> label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-active,
|
||||
.options-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.options-enter-from,
|
||||
.options-leave-to {
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-to,
|
||||
.options-leave-from {
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
<template>
|
||||
<span v-if="typeOnly" class="environment">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
!['resourcepack', 'shader'].includes(type) &&
|
||||
!(type === 'plugin' && search) &&
|
||||
!categories.includes('datapack')
|
||||
"
|
||||
class="environment"
|
||||
>
|
||||
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientOrServerLabel) }}
|
||||
</template>
|
||||
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientAndServerLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(clientSide === 'optional' || clientSide === 'required') &&
|
||||
(serverSide === 'optional' || serverSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(serverSide === 'optional' || serverSide === 'required') &&
|
||||
(clientSide === 'optional' || clientSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.serverLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.unsupportedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="alwaysShow">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="typeOnly" class="environment">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
!['resourcepack', 'shader'].includes(type) &&
|
||||
!(type === 'plugin' && search) &&
|
||||
!categories.includes('datapack')
|
||||
"
|
||||
class="environment"
|
||||
>
|
||||
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientOrServerLabel) }}
|
||||
</template>
|
||||
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientAndServerLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(clientSide === 'optional' || clientSide === 'required') &&
|
||||
(serverSide === 'optional' || serverSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.clientLabel) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
(serverSide === 'optional' || serverSide === 'required') &&
|
||||
(clientSide === 'optional' || clientSide === 'unsupported')
|
||||
"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.serverLabel) }}
|
||||
</template>
|
||||
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.unsupportedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="alwaysShow">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.typeLabel, { type: type }) }}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { GlobeIcon, ClientIcon, ServerIcon, InfoIcon } from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const messages = defineMessages({
|
||||
clientLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client',
|
||||
defaultMessage: 'Client',
|
||||
},
|
||||
clientAndServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-and-server',
|
||||
defaultMessage: 'Client and server',
|
||||
},
|
||||
clientOrServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-or-server',
|
||||
defaultMessage: 'Client or server',
|
||||
},
|
||||
serverLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.type',
|
||||
defaultMessage: 'A {type}',
|
||||
},
|
||||
unsupportedLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.unsupported',
|
||||
defaultMessage: 'Unsupported',
|
||||
},
|
||||
clientLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client',
|
||||
defaultMessage: 'Client',
|
||||
},
|
||||
clientAndServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-and-server',
|
||||
defaultMessage: 'Client and server',
|
||||
},
|
||||
clientOrServerLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.client-or-server',
|
||||
defaultMessage: 'Client or server',
|
||||
},
|
||||
serverLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.type',
|
||||
defaultMessage: 'A {type}',
|
||||
},
|
||||
unsupportedLabel: {
|
||||
id: 'omorphia.component.environment-indicator.label.unsupported',
|
||||
defaultMessage: 'Unsupported',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
type: string
|
||||
serverSide?: string
|
||||
clientSide?: string
|
||||
typeOnly?: boolean
|
||||
alwaysShow?: boolean
|
||||
search?: boolean
|
||||
categories?: string[]
|
||||
}>(),
|
||||
{
|
||||
type: 'mod',
|
||||
serverSide: '',
|
||||
clientSide: '',
|
||||
typeOnly: false,
|
||||
alwaysShow: false,
|
||||
search: false,
|
||||
categories: () => [],
|
||||
},
|
||||
defineProps<{
|
||||
type: string
|
||||
serverSide?: string
|
||||
clientSide?: string
|
||||
typeOnly?: boolean
|
||||
alwaysShow?: boolean
|
||||
search?: boolean
|
||||
categories?: string[]
|
||||
}>(),
|
||||
{
|
||||
type: 'mod',
|
||||
serverSide: '',
|
||||
clientSide: '',
|
||||
typeOnly: false,
|
||||
alwaysShow: false,
|
||||
search: false,
|
||||
categories: () => [],
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.environment {
|
||||
display: flex;
|
||||
color: var(--color-text) !important;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
align-items: center;
|
||||
svg {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
display: flex;
|
||||
color: var(--color-text) !important;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
align-items: center;
|
||||
svg {
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,120 +1,121 @@
|
||||
<template>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<component :is="icon" class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="!description">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<p v-else class="text-lg text-secondary">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-8 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<component :is="icon" class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">{{ title }}</h1>
|
||||
</div>
|
||||
<div v-if="!description">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<p v-else class="text-lg text-secondary">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="errorDetails" class="my-4 w-full rounded-lg border border-divider bg-bg-raised">
|
||||
<div class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="detail in errorDetails.filter((detail) => detail.type !== 'hidden')"
|
||||
:key="detail.label"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div v-if="detail.type === 'inline'" class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="rounded-lg bg-code-bg px-2 py-1 text-sm text-code-text">
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorDetails" class="my-4 w-full rounded-lg border border-divider bg-bg-raised">
|
||||
<div class="divide-y divide-divider">
|
||||
<div
|
||||
v-for="detail in errorDetails.filter((detail) => detail.type !== 'hidden')"
|
||||
:key="detail.label"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div v-if="detail.type === 'inline'" class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="rounded-lg bg-code-bg px-2 py-1 text-sm text-code-text">
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail.type === 'block'" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
</div>
|
||||
<div class="w-full overflow-hidden rounded-lg bg-code-bg p-3">
|
||||
<code
|
||||
class="block w-full overflow-x-auto break-words text-sm text-code-text whitespace-pre-wrap"
|
||||
>
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="detail.type === 'block'" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-secondary">{{ detail.label }}</span>
|
||||
</div>
|
||||
<div class="w-full overflow-hidden rounded-lg bg-code-bg p-3">
|
||||
<code
|
||||
class="block w-full overflow-x-auto break-words text-sm text-code-text whitespace-pre-wrap"
|
||||
>
|
||||
{{ detail.value }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex !w-full flex-row gap-4">
|
||||
<ButtonStyled
|
||||
v-if="action"
|
||||
size="large"
|
||||
:color="action.color || 'brand'"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<button class="!w-full">
|
||||
<component :is="action.icon" v-if="action.icon && !action.showAltIcon" class="size-4" />
|
||||
<component
|
||||
:is="action.altIcon"
|
||||
v-else-if="action.icon && action.showAltIcon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mt-4 flex !w-full flex-row gap-4">
|
||||
<ButtonStyled
|
||||
v-if="action"
|
||||
size="large"
|
||||
:color="action.color || 'brand'"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
<button class="!w-full">
|
||||
<component :is="action.icon" v-if="action.icon && !action.showAltIcon" class="size-4" />
|
||||
<component
|
||||
:is="action.altIcon"
|
||||
v-else-if="action.icon && action.showAltIcon"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-if="errorDetails" size="large" color="standard" @click="copyErrorInformation">
|
||||
<button class="!w-full">
|
||||
<CopyIcon v-if="!infoCopied" class="size-4" />
|
||||
<CheckIcon v-else class="size-4" />
|
||||
Copy Information
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="errorDetails" size="large" color="standard" @click="copyErrorInformation">
|
||||
<button class="!w-full">
|
||||
<CopyIcon v-if="!infoCopied" class="size-4" />
|
||||
<CheckIcon v-else class="size-4" />
|
||||
Copy Information
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { CopyIcon, CheckIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, CopyIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const infoCopied = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
icon: Component
|
||||
errorDetails?: {
|
||||
label?: string
|
||||
value?: string
|
||||
type?: 'inline' | 'block' | 'hidden'
|
||||
}[]
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
color?: 'brand' | 'standard' | 'red' | 'orange' | 'blue'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
altIcon?: Component
|
||||
showAltIcon?: boolean
|
||||
}
|
||||
title: string
|
||||
description?: string
|
||||
icon: Component
|
||||
errorDetails?: {
|
||||
label?: string
|
||||
value?: string
|
||||
type?: 'inline' | 'block' | 'hidden'
|
||||
}[]
|
||||
action?: {
|
||||
label: string
|
||||
onClick: () => void
|
||||
color?: 'brand' | 'standard' | 'red' | 'orange' | 'blue'
|
||||
disabled?: boolean
|
||||
icon?: Component
|
||||
altIcon?: Component
|
||||
showAltIcon?: boolean
|
||||
}
|
||||
}>()
|
||||
|
||||
const copyErrorInformation = async () => {
|
||||
if (!props.errorDetails || props.errorDetails.length === 0) return
|
||||
if (!props.errorDetails || props.errorDetails.length === 0) return
|
||||
|
||||
const formattedErrorInfo = props.errorDetails
|
||||
.filter((detail) => detail.label && detail.value)
|
||||
.map((detail) => `${detail.label}: ${detail.value}`)
|
||||
.join('\n\n')
|
||||
const formattedErrorInfo = props.errorDetails
|
||||
.filter((detail) => detail.label && detail.value)
|
||||
.map((detail) => `${detail.label}: ${detail.value}`)
|
||||
.join('\n\n')
|
||||
|
||||
await navigator.clipboard.writeText(formattedErrorInfo)
|
||||
infoCopied.value = true
|
||||
setTimeout(() => {
|
||||
infoCopied.value = false
|
||||
}, 2000)
|
||||
await navigator.clipboard.writeText(formattedErrorInfo)
|
||||
infoCopied.value = true
|
||||
setTimeout(() => {
|
||||
infoCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<label :class="{ 'long-style': longStyle }" @drop.prevent="handleDrop" @dragover.prevent>
|
||||
<slot />
|
||||
{{ prompt }}
|
||||
<input
|
||||
type="file"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:disabled="disabled"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</label>
|
||||
<label :class="{ 'long-style': longStyle }" @drop.prevent="handleDrop" @dragover.prevent>
|
||||
<slot />
|
||||
{{ prompt }}
|
||||
<input
|
||||
type="file"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
:disabled="disabled"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -17,91 +17,91 @@ import { fileIsValid } from '@modrinth/utils'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label {
|
||||
flex-direction: unset;
|
||||
max-height: unset;
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
&.long-style {
|
||||
display: flex;
|
||||
padding: 1.5rem 2rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: dashed 0.3rem var(--color-contrast);
|
||||
cursor: pointer;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
flex-direction: unset;
|
||||
max-height: unset;
|
||||
svg {
|
||||
height: 1rem;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
&.long-style {
|
||||
display: flex;
|
||||
padding: 1.5rem 2rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
background-color: var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
border: dashed 0.3rem var(--color-contrast);
|
||||
cursor: pointer;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="(showAllOptions && options.length > 0) || options.length > 1"
|
||||
class="flex flex-wrap gap-1 items-center"
|
||||
>
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in options"
|
||||
:key="`filter-${filter.id}`"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleFilter(filter.id)"
|
||||
>
|
||||
{{ formatMessage(filter.message) }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="(showAllOptions && options.length > 0) || options.length > 1"
|
||||
class="flex flex-wrap gap-1 items-center"
|
||||
>
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in options"
|
||||
:key="`filter-${filter.id}`"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleFilter(filter.id)"
|
||||
>
|
||||
{{ formatMessage(filter.message) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon } from '@modrinth/assets'
|
||||
import { watch } from 'vue'
|
||||
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type FilterBarOption = {
|
||||
id: string
|
||||
message: MessageDescriptor
|
||||
id: string
|
||||
message: MessageDescriptor
|
||||
}
|
||||
|
||||
const selectedFilters = defineModel<string[]>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
options: FilterBarOption[]
|
||||
showAllOptions?: boolean
|
||||
options: FilterBarOption[]
|
||||
showAllOptions?: boolean
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.options,
|
||||
() => {
|
||||
for (let i = 0; i < selectedFilters.value.length; i++) {
|
||||
const option = selectedFilters.value[i]
|
||||
if (!props.options.some((x) => x.id === option)) {
|
||||
selectedFilters.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
() => props.options,
|
||||
() => {
|
||||
for (let i = 0; i < selectedFilters.value.length; i++) {
|
||||
const option = selectedFilters.value[i]
|
||||
if (!props.options.some((x) => x.id === option)) {
|
||||
selectedFilters.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleFilter(option: string) {
|
||||
if (selectedFilters.value.includes(option)) {
|
||||
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
|
||||
} else {
|
||||
selectedFilters.value.push(option)
|
||||
}
|
||||
if (selectedFilters.value.includes(option)) {
|
||||
selectedFilters.value.splice(selectedFilters.value.indexOf(option), 1)
|
||||
} else {
|
||||
selectedFilters.value.push(option)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="to"
|
||||
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group w-fit"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon
|
||||
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
||||
/>
|
||||
</AutoLink>
|
||||
<AutoLink
|
||||
:to="to"
|
||||
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group w-fit"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon
|
||||
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
||||
/>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AutoLink from './AutoLink.vue'
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from './AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
to: unknown
|
||||
to: unknown
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,113 +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>
|
||||
<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);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-weight: bold;
|
||||
color: var(--color-contrast);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
&::after {
|
||||
content: '';
|
||||
animation: dots 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
25% {
|
||||
content: '';
|
||||
}
|
||||
50% {
|
||||
content: '.';
|
||||
}
|
||||
75% {
|
||||
content: '..';
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
content: '...';
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
&::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(2)::before {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(3)::before {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
&:nth-child(4)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
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%);
|
||||
}
|
||||
from {
|
||||
transform: translateX(-80%);
|
||||
}
|
||||
50%,
|
||||
to {
|
||||
transform: translateX(80%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,113 +1,114 @@
|
||||
<template>
|
||||
<ButtonStyled>
|
||||
<PopoutMenu
|
||||
v-if="options.length > 1 || showAlways"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
:dropdown-class="dropdownClass"
|
||||
:tooltip="tooltip"
|
||||
@open="
|
||||
() => {
|
||||
searchQuery = ''
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<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"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ScrollablePanel v-if="search">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</ScrollablePanel>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<PopoutMenu
|
||||
v-if="options.length > 1 || showAlways"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:position="position"
|
||||
:direction="direction"
|
||||
:dropdown-id="dropdownId"
|
||||
:dropdown-class="dropdownClass"
|
||||
:tooltip="tooltip"
|
||||
@open="
|
||||
() => {
|
||||
searchQuery = ''
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<DropdownIcon class="h-5 w-5 text-secondary" />
|
||||
<template #menu>
|
||||
<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"
|
||||
@keydown.enter="
|
||||
() => {
|
||||
toggleOption(filteredOptions[0])
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ScrollablePanel v-if="search">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</ScrollablePanel>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<Button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`option-${index}`"
|
||||
:transparent="!manyValues.includes(option)"
|
||||
:action="() => toggleOption(option)"
|
||||
class="!w-full"
|
||||
:color="manyValues.includes(option) ? 'secondary' : 'default'"
|
||||
>
|
||||
<slot name="option" :option="option">{{ getOptionLabel(option) }}</slot>
|
||||
<CheckIcon
|
||||
class="h-5 w-5 text-contrast ml-auto transition-opacity"
|
||||
:class="{ 'opacity-0': !manyValues.includes(option) }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, PopoutMenu, Button } from '../index'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Button, ButtonStyled, PopoutMenu } from '../index'
|
||||
import ScrollablePanel from './ScrollablePanel.vue'
|
||||
|
||||
type Option = string | number | object
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: Option[]
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
position?: string
|
||||
direction?: string
|
||||
displayName?: (option: Option) => string
|
||||
search?: boolean
|
||||
dropdownId?: string
|
||||
dropdownClass?: string
|
||||
showAlways?: boolean
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
position: 'auto',
|
||||
direction: 'auto',
|
||||
displayName: undefined,
|
||||
search: false,
|
||||
dropdownId: '',
|
||||
dropdownClass: '',
|
||||
showAlways: false,
|
||||
tooltip: '',
|
||||
},
|
||||
defineProps<{
|
||||
modelValue: Option[]
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
position?: string
|
||||
direction?: string
|
||||
displayName?: (option: Option) => string
|
||||
search?: boolean
|
||||
dropdownId?: string
|
||||
dropdownClass?: string
|
||||
showAlways?: boolean
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
position: 'auto',
|
||||
direction: 'auto',
|
||||
displayName: undefined,
|
||||
search: false,
|
||||
dropdownId: '',
|
||||
dropdownClass: '',
|
||||
showAlways: false,
|
||||
tooltip: '',
|
||||
},
|
||||
)
|
||||
|
||||
function getOptionLabel(option: Option): string {
|
||||
return props.displayName?.(option) ?? (option as string)
|
||||
return props.displayName?.(option) ?? (option as string)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
@@ -117,33 +118,33 @@ const searchInput = ref()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const manyValues = computed({
|
||||
get() {
|
||||
return props.modelValue || selectedValues.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
selectedValues.value = newValue
|
||||
},
|
||||
get() {
|
||||
return props.modelValue || selectedValues.value
|
||||
},
|
||||
set(newValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
selectedValues.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
return props.options.filter(
|
||||
(x) =>
|
||||
!searchQuery.value ||
|
||||
getOptionLabel(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
return props.options.filter(
|
||||
(x) =>
|
||||
!searchQuery.value ||
|
||||
getOptionLabel(x).toLowerCase().includes(searchQuery.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
function toggleOption(id: Option) {
|
||||
if (manyValues.value.includes(id)) {
|
||||
manyValues.value = manyValues.value.filter((x) => x !== id)
|
||||
} else {
|
||||
manyValues.value = [...manyValues.value, id]
|
||||
}
|
||||
if (manyValues.value.includes(id)) {
|
||||
manyValues.value = manyValues.value.filter((x) => x !== id)
|
||||
} else {
|
||||
manyValues.value = [...manyValues.value, id]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,125 +1,126 @@
|
||||
<template>
|
||||
<PopoutMenu
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
|
||||
<div
|
||||
v-if="isDivider(option)"
|
||||
:key="`divider-${index}`"
|
||||
class="h-px mx-3 my-2 bg-button-bg"
|
||||
></div>
|
||||
<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: MouseEvent) => {
|
||||
option.action?.(event)
|
||||
if (!option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:link="option.link ? option.link : undefined"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!$slots[option.id]">{{ option.id }}</template>
|
||||
<slot :name="option.id"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<PopoutMenu
|
||||
ref="dropdown"
|
||||
v-bind="$attrs"
|
||||
:disabled="disabled"
|
||||
:dropdown-id="dropdownId"
|
||||
:tooltip="tooltip"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #menu>
|
||||
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
|
||||
<div
|
||||
v-if="isDivider(option)"
|
||||
:key="`divider-${index}`"
|
||||
class="h-px mx-3 my-2 bg-button-bg"
|
||||
></div>
|
||||
<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: MouseEvent) => {
|
||||
option.action?.(event)
|
||||
if (!option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:link="option.link ? option.link : undefined"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
() => {
|
||||
if (option.link && !option.remainOnClick) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!$slots[option.id]">{{ option.id }}</template>
|
||||
<slot :name="option.id"></slot>
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
import PopoutMenu from './PopoutMenu.vue'
|
||||
|
||||
interface BaseOption {
|
||||
shown?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
interface Divider extends BaseOption {
|
||||
divider?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface Item extends BaseOption {
|
||||
id: string
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| 'highlight'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
id: string
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| 'highlight'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
hoverFilled?: boolean
|
||||
hoverFilledOnly?: boolean
|
||||
remainOnClick?: boolean
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export type Option = Divider | Item
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
dropdownId?: string
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
defineProps<{
|
||||
options: Option[]
|
||||
disabled?: boolean
|
||||
dropdownId?: string
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
options: () => [],
|
||||
disabled: false,
|
||||
dropdownId: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const dropdown: Ref<InstanceType<typeof PopoutMenu> | null> = ref(null)
|
||||
|
||||
const close = () => {
|
||||
dropdown.value?.hide()
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
dropdown.value?.show()
|
||||
dropdown.value?.show()
|
||||
}
|
||||
|
||||
function isDivider(option: BaseOption): option is Divider {
|
||||
return 'divider' in option
|
||||
return 'divider' in option
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
@@ -127,15 +128,15 @@ defineExpose({ open, close })
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.btn {
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
--text-color: var(--color-base);
|
||||
--background-color: transparent;
|
||||
justify-content: flex-start;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
--text-color: var(--color-base);
|
||||
--background-color: transparent;
|
||||
justify-content: flex-start;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-xs);
|
||||
}
|
||||
&:not(:last-child) {
|
||||
margin-bottom: var(--gap-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,121 +1,121 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rightSidebar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="omorphia__page"
|
||||
:class="{
|
||||
'right-sidebar': rightSidebar,
|
||||
'has-sidebar': !!$slots.sidebar,
|
||||
'has-header': !!$slots.header,
|
||||
'has-footer': !!$slots.footer,
|
||||
}"
|
||||
>
|
||||
<div v-if="!!$slots.header" class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="!!$slots.sidebar" class="sidebar">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="!!$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="omorphia__page"
|
||||
:class="{
|
||||
'right-sidebar': rightSidebar,
|
||||
'has-sidebar': !!$slots.sidebar,
|
||||
'has-header': !!$slots.header,
|
||||
'has-footer': !!$slots.footer,
|
||||
}"
|
||||
>
|
||||
<div v-if="!!$slots.header" class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div v-if="!!$slots.sidebar" class="sidebar">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="!!$slots.footer" class="footer">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.75rem;
|
||||
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
.header {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.omorphia__page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
.omorphia__page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
column-gap: 0.75rem;
|
||||
|
||||
&.has-sidebar {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
&.has-sidebar {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
}
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'sidebar content' auto
|
||||
'footer content' auto
|
||||
'dummy content' 1fr
|
||||
/ 20rem 1fr;
|
||||
}
|
||||
|
||||
&.right-sidebar {
|
||||
grid-template:
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
&.right-sidebar {
|
||||
grid-template:
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
}
|
||||
}
|
||||
&.has-header {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'content sidebar' auto
|
||||
'content footer' auto
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
.content {
|
||||
max-width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-width: 20rem;
|
||||
width: 20rem;
|
||||
}
|
||||
.sidebar {
|
||||
min-width: 20rem;
|
||||
width: 20rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
.omorphia__page.has-sidebar {
|
||||
.content {
|
||||
width: calc(60rem - 0.75rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,118 +1,119 @@
|
||||
<template>
|
||||
<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"
|
||||
:key="'page-' + item + '-' + index"
|
||||
:class="{
|
||||
'page-number': page !== item,
|
||||
shrink: item !== '-' && item > 99,
|
||||
}"
|
||||
class="page-number-container"
|
||||
>
|
||||
<div v-if="item === '-'">
|
||||
<GapIcon />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
circular
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'standard' : 'transparent'"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
:key="'page-' + item + '-' + index"
|
||||
:class="{
|
||||
'page-number': page !== item,
|
||||
shrink: item !== '-' && item > 99,
|
||||
}"
|
||||
class="page-number-container"
|
||||
>
|
||||
<div v-if="item === '-'">
|
||||
<GapIcon />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
circular
|
||||
:color="page === item ? 'brand' : 'standard'"
|
||||
:type="page === item ? 'standard' : 'transparent'"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon, GapIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-page': [page: number]
|
||||
'switch-page': [page: number]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number
|
||||
count: number
|
||||
linkFunction?: (page: number) => string | undefined
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
linkFunction: (page: number) => void page,
|
||||
},
|
||||
defineProps<{
|
||||
page: number
|
||||
count: number
|
||||
linkFunction?: (page: number) => string | undefined
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
count: 1,
|
||||
linkFunction: (page: number) => void page,
|
||||
},
|
||||
)
|
||||
|
||||
const pages = computed(() => {
|
||||
const pages: ('-' | number)[] = []
|
||||
const pages: ('-' | number)[] = []
|
||||
|
||||
const first = 1
|
||||
const last = props.count
|
||||
const current = props.page
|
||||
const prev = current - 1
|
||||
const next = current + 1
|
||||
const gap = '-'
|
||||
const first = 1
|
||||
const last = props.count
|
||||
const current = props.page
|
||||
const prev = current - 1
|
||||
const next = current + 1
|
||||
const gap = '-'
|
||||
|
||||
if (prev > first) {
|
||||
pages.push(first)
|
||||
}
|
||||
if (prev > first + 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (prev >= first) {
|
||||
pages.push(prev)
|
||||
}
|
||||
pages.push(current)
|
||||
if (next <= last) {
|
||||
pages.push(next)
|
||||
}
|
||||
if (next < last - 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (next < last) {
|
||||
pages.push(last)
|
||||
}
|
||||
if (prev > first) {
|
||||
pages.push(first)
|
||||
}
|
||||
if (prev > first + 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (prev >= first) {
|
||||
pages.push(prev)
|
||||
}
|
||||
pages.push(current)
|
||||
if (next <= last) {
|
||||
pages.push(next)
|
||||
}
|
||||
if (next < last - 1) {
|
||||
pages.push(gap)
|
||||
}
|
||||
if (next < last) {
|
||||
pages.push(last)
|
||||
}
|
||||
|
||||
return pages
|
||||
return pages
|
||||
})
|
||||
|
||||
function switchPage(newPage: number) {
|
||||
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
|
||||
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
placement="bottom-end"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
|
||||
<slot></slot>
|
||||
</button>
|
||||
<template #popper="{ hide: hideFunction }">
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
<div ref="menu" class="contents">
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
no-auto-focus
|
||||
:aria-id="dropdownId || null"
|
||||
placement="bottom-end"
|
||||
:class="dropdownClass"
|
||||
@apply-hide="focusTrigger"
|
||||
@apply-show="focusMenuChild"
|
||||
>
|
||||
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
|
||||
<slot></slot>
|
||||
</button>
|
||||
<template #popper="{ hide: hideFunction }">
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
<div ref="menu" class="contents">
|
||||
<slot name="menu"> </slot>
|
||||
</div>
|
||||
<button class="dummy-button" @focusin="hideAndFocusTrigger(hideFunction)"></button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -30,68 +30,68 @@ const menu = ref()
|
||||
const dropdown = ref()
|
||||
|
||||
defineProps({
|
||||
dropdownId: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
dropdownId: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
function focusMenuChild() {
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
setTimeout(() => {
|
||||
if (menu.value && menu.value.children && menu.value.children.length > 0) {
|
||||
menu.value.children[0].focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hideAndFocusTrigger(hide) {
|
||||
hide()
|
||||
focusTrigger()
|
||||
hide()
|
||||
focusTrigger()
|
||||
}
|
||||
|
||||
function focusTrigger() {
|
||||
trigger.value.focus()
|
||||
trigger.value.focus()
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
function hide() {
|
||||
dropdown.value.hide()
|
||||
dropdown.value.hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
dropdown.value.show()
|
||||
dropdown.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<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;
|
||||
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>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
}>(),
|
||||
{
|
||||
checked: false,
|
||||
},
|
||||
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" />
|
||||
<RadioButtonCheckedIcon v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="" role="button" @click="() => {}">
|
||||
<slot name="preview" />
|
||||
<div>
|
||||
<RadioButtonIcon v-if="!checked" class="w-4 h-4" />
|
||||
<RadioButtonCheckedIcon v-else class="w-4 h-4" />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,85 +2,85 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
},
|
||||
defineProps<{
|
||||
progress: number
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const colors = {
|
||||
brand: {
|
||||
fg: 'bg-brand',
|
||||
bg: 'bg-brand-highlight',
|
||||
},
|
||||
green: {
|
||||
fg: 'bg-green',
|
||||
bg: 'bg-bg-green',
|
||||
},
|
||||
red: {
|
||||
fg: 'bg-red',
|
||||
bg: 'bg-bg-red',
|
||||
},
|
||||
orange: {
|
||||
fg: 'bg-orange',
|
||||
bg: 'bg-bg-orange',
|
||||
},
|
||||
blue: {
|
||||
fg: 'bg-blue',
|
||||
bg: 'bg-bg-blue',
|
||||
},
|
||||
purple: {
|
||||
fg: 'bg-purple',
|
||||
bg: 'bg-bg-purple',
|
||||
},
|
||||
gray: {
|
||||
fg: 'bg-gray',
|
||||
bg: 'bg-bg-gray',
|
||||
},
|
||||
brand: {
|
||||
fg: 'bg-brand',
|
||||
bg: 'bg-brand-highlight',
|
||||
},
|
||||
green: {
|
||||
fg: 'bg-green',
|
||||
bg: 'bg-bg-green',
|
||||
},
|
||||
red: {
|
||||
fg: 'bg-red',
|
||||
bg: 'bg-bg-red',
|
||||
},
|
||||
orange: {
|
||||
fg: 'bg-orange',
|
||||
bg: 'bg-bg-orange',
|
||||
},
|
||||
blue: {
|
||||
fg: 'bg-blue',
|
||||
bg: 'bg-bg-blue',
|
||||
},
|
||||
purple: {
|
||||
fg: 'bg-purple',
|
||||
bg: 'bg-bg-purple',
|
||||
},
|
||||
gray: {
|
||||
fg: 'bg-gray',
|
||||
bg: 'bg-bg-gray',
|
||||
},
|
||||
}
|
||||
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.progress-bar {
|
||||
transition: width 0.2s ease-in-out;
|
||||
transition: width 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.progress-bar--waiting {
|
||||
animation: progress-bar-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
animation: progress-bar-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes progress-bar-waiting {
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 20%;
|
||||
}
|
||||
50% {
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 20%;
|
||||
}
|
||||
0% {
|
||||
left: -50%;
|
||||
width: 20%;
|
||||
}
|
||||
50% {
|
||||
width: 60%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="radial-header-divider" />
|
||||
</div>
|
||||
<div>
|
||||
<div :style="colorClasses" class="radial-header relative" v-bind="$attrs">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="radial-header-divider" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
}>(),
|
||||
{
|
||||
color: 'brand',
|
||||
},
|
||||
defineProps<{
|
||||
color?: 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
}>(),
|
||||
{
|
||||
color: 'brand',
|
||||
},
|
||||
)
|
||||
|
||||
const colorClasses = computed(
|
||||
() =>
|
||||
`--_radial-bg: var(--color-${props.color}-highlight);--_radial-border: var(--color-${props.color});`,
|
||||
() =>
|
||||
`--_radial-bg: var(--color-${props.color}-highlight);--_radial-border: var(--color-${props.color});`,
|
||||
)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.radial-header {
|
||||
background-image: radial-gradient(50% 100% at 50% 100%, var(--_radial-bg) 10%, #ffffff00 100%);
|
||||
position: relative;
|
||||
background-image: radial-gradient(50% 100% at 50% 100%, var(--_radial-bg) 10%, #ffffff00 100%);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ffffff00 0%,
|
||||
var(--_radial-border) 50%,
|
||||
#ffffff00 100%
|
||||
);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
#ffffff00 0%,
|
||||
var(--_radial-border) 50%,
|
||||
#ffffff00 100%
|
||||
);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`radio-button-${index}`"
|
||||
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
:class="{
|
||||
'text-contrast bg-button-bg': selected === item,
|
||||
'text-primary bg-transparent': selected !== item,
|
||||
}"
|
||||
@click="selected = item"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonIcon v-else class="h-5 w-5" />
|
||||
<slot :item="item" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="`radio-button-${index}`"
|
||||
class="p-0 py-2 px-2 border-0 font-medium flex gap-2 transition-all items-center cursor-pointer active:scale-95 hover:bg-button-bg rounded-xl"
|
||||
:class="{
|
||||
'text-contrast bg-button-bg': selected === item,
|
||||
'text-primary bg-transparent': selected !== item,
|
||||
}"
|
||||
@click="selected = item"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected === item" class="text-brand h-5 w-5" />
|
||||
<RadioButtonIcon v-else class="h-5 w-5" />
|
||||
<slot :item="item" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" generic="T">
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon } from '@modrinth/assets'
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
items: T[]
|
||||
forceSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
forceSelection: false,
|
||||
},
|
||||
defineProps<{
|
||||
modelValue: T
|
||||
items: T[]
|
||||
forceSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
forceSelection: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selected = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
if (props.items.length > 0 && props.forceSelection && !props.modelValue) {
|
||||
selected.value = props.items[0]
|
||||
selected.value = props.items[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
<template>
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="scrollablePane"
|
||||
:class="{
|
||||
'max-h-[19rem]': !disableScrolling,
|
||||
}"
|
||||
class="scrollable-pane"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scrollable-pane-wrapper">
|
||||
<div
|
||||
class="wrapper-wrapper"
|
||||
:class="{
|
||||
'top-fade': !scrollableAtTop && !disableScrolling,
|
||||
'bottom-fade': !scrollableAtBottom && !disableScrolling,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="scrollablePane"
|
||||
:class="{
|
||||
'max-h-[19rem]': !disableScrolling,
|
||||
}"
|
||||
class="scrollable-pane"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableScrolling: false,
|
||||
},
|
||||
defineProps<{
|
||||
disableScrolling?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableScrolling: false,
|
||||
},
|
||||
)
|
||||
|
||||
const scrollableAtTop = ref(true)
|
||||
@@ -38,85 +38,85 @@ const scrollableAtBottom = ref(false)
|
||||
const scrollablePane = ref(null)
|
||||
let resizeObserver
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver(function () {
|
||||
if (scrollablePane.value) {
|
||||
updateFade(
|
||||
scrollablePane.value.scrollTop,
|
||||
scrollablePane.value.offsetHeight,
|
||||
scrollablePane.value.scrollHeight,
|
||||
)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(scrollablePane.value)
|
||||
resizeObserver = new ResizeObserver(function () {
|
||||
if (scrollablePane.value) {
|
||||
updateFade(
|
||||
scrollablePane.value.scrollTop,
|
||||
scrollablePane.value.offsetHeight,
|
||||
scrollablePane.value.scrollHeight,
|
||||
)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(scrollablePane.value)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
})
|
||||
function updateFade(scrollTop, offsetHeight, scrollHeight) {
|
||||
console.log(scrollTop, offsetHeight, scrollHeight)
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
console.log(scrollTop, offsetHeight, scrollHeight)
|
||||
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
|
||||
scrollableAtTop.value = scrollTop <= 0
|
||||
}
|
||||
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
updateFade(scrollTop, offsetHeight, scrollHeight)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@property --_top-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
@property --_bottom-fade-height {
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
syntax: '<length-percentage>';
|
||||
inherits: false;
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
.scrollable-pane-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wrapper-wrapper {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
--_top-fade-height 0.05s linear,
|
||||
--_bottom-fade-height 0.05s linear;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition:
|
||||
--_top-fade-height 0.05s linear,
|
||||
--_bottom-fade-height 0.05s linear;
|
||||
|
||||
--_fade-height: 3rem;
|
||||
--_fade-height: 3rem;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
transparent,
|
||||
rgb(0 0 0 / 100%) var(--_top-fade-height, 0%),
|
||||
rgb(0 0 0 / 100%) calc(100% - var(--_bottom-fade-height, 0%)),
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
transparent,
|
||||
rgb(0 0 0 / 100%) var(--_top-fade-height, 0%),
|
||||
rgb(0 0 0 / 100%) calc(100% - var(--_bottom-fade-height, 0%)),
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
&.top-fade {
|
||||
--_top-fade-height: var(--_fade-height);
|
||||
}
|
||||
&.top-fade {
|
||||
--_top-fade-height: var(--_fade-height);
|
||||
}
|
||||
|
||||
&.bottom-fade {
|
||||
--_bottom-fade-height: var(--_fade-height);
|
||||
}
|
||||
&.bottom-fade {
|
||||
--_bottom-fade-height: var(--_fade-height);
|
||||
}
|
||||
}
|
||||
.scrollable-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="level === 'survey'"
|
||||
class="flex items-center gap-2 border-2 border-solid border-brand-purple bg-bg-purple p-4 rounded-2xl"
|
||||
>
|
||||
<span class="text-contrast font-bold">Survey ID:</span> <CopyCode :text="message" />
|
||||
</div>
|
||||
<Admonition v-else :type="NOTICE_TYPE[level]">
|
||||
<template #header>
|
||||
<template v-if="!hideDefaultTitle">
|
||||
{{ formatMessage(heading) }}
|
||||
</template>
|
||||
<template v-if="title">
|
||||
<template v-if="hideDefaultTitle">
|
||||
{{ title.substring(1) }}
|
||||
</template>
|
||||
<template v-else> - {{ title }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="dismissable" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
<div
|
||||
v-if="level === 'survey'"
|
||||
class="flex items-center gap-2 border-2 border-solid border-brand-purple bg-bg-purple p-4 rounded-2xl"
|
||||
>
|
||||
<span class="text-contrast font-bold">Survey ID:</span> <CopyCode :text="message" />
|
||||
</div>
|
||||
<Admonition v-else :type="NOTICE_TYPE[level]">
|
||||
<template #header>
|
||||
<template v-if="!hideDefaultTitle">
|
||||
{{ formatMessage(heading) }}
|
||||
</template>
|
||||
<template v-if="title">
|
||||
<template v-if="hideDefaultTitle">
|
||||
{{ title.substring(1) }}
|
||||
</template>
|
||||
<template v-else> - {{ title }}</template>
|
||||
</template>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="dismissable" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismiss)"
|
||||
@click="() => (preview ? {} : emit('dismiss'))"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<div v-if="message" class="markdown-body" v-html="renderString(message)" />
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -36,64 +36,65 @@ import { XIcon } from '@modrinth/assets'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from './Admonition.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import CopyCode from './CopyCode.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const emit = defineEmits<{
|
||||
(e: 'dismiss'): void
|
||||
(e: 'dismiss'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
level: string
|
||||
message: string
|
||||
dismissable: boolean
|
||||
preview?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
title: undefined,
|
||||
},
|
||||
defineProps<{
|
||||
level: string
|
||||
message: string
|
||||
dismissable: boolean
|
||||
preview?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
title: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const hideDefaultTitle = computed(
|
||||
() => props.title && props.title.length > 1 && props.title.startsWith('\\'),
|
||||
() => props.title && props.title.length > 1 && props.title.startsWith('\\'),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
id: 'servers.notice.heading.info',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
attention: {
|
||||
id: 'servers.notice.heading.attention',
|
||||
defaultMessage: 'Attention',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'servers.notice.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
info: {
|
||||
id: 'servers.notice.heading.info',
|
||||
defaultMessage: 'Info',
|
||||
},
|
||||
attention: {
|
||||
id: 'servers.notice.heading.attention',
|
||||
defaultMessage: 'Attention',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'servers.notice.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
})
|
||||
|
||||
const NOTICE_HEADINGS: Record<string, MessageDescriptor> = {
|
||||
info: messages.info,
|
||||
warn: messages.attention,
|
||||
critical: messages.attention,
|
||||
info: messages.info,
|
||||
warn: messages.attention,
|
||||
critical: messages.attention,
|
||||
}
|
||||
|
||||
const NOTICE_TYPE: Record<string, 'info' | 'warning' | 'critical'> = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
critical: 'critical',
|
||||
}
|
||||
|
||||
const heading = computed(() => NOTICE_HEADINGS[props.level] ?? messages.info)
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.markdown-body > *:first-child {
|
||||
margin-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<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>
|
||||
<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'
|
||||
icon?: Component
|
||||
formattedName: string
|
||||
color?: 'brand' | 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
<template>
|
||||
<div class="root-container">
|
||||
<div class="slider-component">
|
||||
<div class="slide-container">
|
||||
<div class="snap-points-wrapper">
|
||||
<div class="snap-points">
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="snap-point"
|
||||
:class="{ green: snapPoint <= currentValue, 'opacity-0': disabled }"
|
||||
:style="{ left: ((snapPoint - min) / (max - min)) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="slider-range">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="value"
|
||||
:value="currentValue"
|
||||
type="number"
|
||||
class="slider-input"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="root-container">
|
||||
<div class="slider-component">
|
||||
<div class="slide-container">
|
||||
<div class="snap-points-wrapper">
|
||||
<div class="snap-points">
|
||||
<div
|
||||
v-for="snapPoint in snapPoints"
|
||||
:key="snapPoint"
|
||||
class="snap-point"
|
||||
:class="{ green: snapPoint <= currentValue, 'opacity-0': disabled }"
|
||||
:style="{ left: ((snapPoint - min) / (max - min)) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="currentValue"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="slider"
|
||||
:class="{
|
||||
disabled: disabled,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:style="{
|
||||
'--current-value': currentValue,
|
||||
'--min-value': min,
|
||||
'--max-value': max,
|
||||
}"
|
||||
@input="onInputWithSnap(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="slider-range">
|
||||
<span> {{ min }} {{ unit }} </span>
|
||||
<span> {{ max }} {{ unit }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="value"
|
||||
:value="currentValue"
|
||||
type="number"
|
||||
class="slider-input"
|
||||
:disabled="disabled"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
@change="onInput(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -58,192 +58,192 @@ import { ref } from 'vue'
|
||||
const emit = defineEmits<{ 'update:modelValue': [number] }>()
|
||||
|
||||
interface Props {
|
||||
modelValue?: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
forceStep?: boolean
|
||||
snapPoints?: number[]
|
||||
snapRange?: number
|
||||
disabled?: boolean
|
||||
unit?: string
|
||||
modelValue?: number
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
forceStep?: boolean
|
||||
snapPoints?: number[]
|
||||
snapRange?: number
|
||||
disabled?: boolean
|
||||
unit?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
forceStep: true,
|
||||
snapPoints: () => [],
|
||||
snapRange: 100,
|
||||
disabled: false,
|
||||
unit: '',
|
||||
modelValue: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
forceStep: true,
|
||||
snapPoints: () => [],
|
||||
snapRange: 100,
|
||||
disabled: false,
|
||||
unit: '',
|
||||
})
|
||||
|
||||
const currentValue = ref(Math.max(props.min, props.modelValue))
|
||||
|
||||
const inputValueValid = (inputValue: number) => {
|
||||
let newValue = inputValue || props.min
|
||||
let newValue = inputValue || props.min
|
||||
|
||||
if (props.forceStep) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
if (props.forceStep) {
|
||||
newValue -= newValue % props.step
|
||||
}
|
||||
newValue = Math.max(props.min, Math.min(newValue, props.max))
|
||||
|
||||
currentValue.value = newValue
|
||||
emit('update:modelValue', currentValue.value)
|
||||
currentValue.value = newValue
|
||||
emit('update:modelValue', currentValue.value)
|
||||
}
|
||||
|
||||
const onInputWithSnap = (value: string) => {
|
||||
let parsedValue = parseInt(value)
|
||||
let parsedValue = parseInt(value)
|
||||
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
for (const snapPoint of props.snapPoints) {
|
||||
const distance = Math.abs(snapPoint - parsedValue)
|
||||
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
if (distance < props.snapRange) {
|
||||
parsedValue = snapPoint
|
||||
}
|
||||
}
|
||||
|
||||
inputValueValid(parsedValue)
|
||||
inputValueValid(parsedValue)
|
||||
}
|
||||
|
||||
const onInput = (value: string) => {
|
||||
inputValueValid(parseInt(value))
|
||||
inputValueValid(parseInt(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.root-container {
|
||||
--transition-speed: 0.2s;
|
||||
--transition-speed: 0.2s;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
--transition-speed: 0s;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
--transition-speed: 0s;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-component,
|
||||
.slide-container {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
|
||||
border-radius: var(--radius-sm);
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
min-height: 0px;
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-sm);
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
min-height: 0px;
|
||||
box-shadow: none;
|
||||
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand),
|
||||
var(--color-brand)
|
||||
calc((var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%),
|
||||
var(--color-base)
|
||||
calc((var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%),
|
||||
var(--color-base) 100%
|
||||
);
|
||||
background-size: 100% 100%;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-brand),
|
||||
var(--color-brand)
|
||||
calc((var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%),
|
||||
var(--color-base)
|
||||
calc((var(--current-value) - var(--min-value)) / (var(--max-value) - var(--min-value)) * 100%),
|
||||
var(--color-base) 100%
|
||||
);
|
||||
background-size: 100% 100%;
|
||||
outline: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider::-moz-range-thumb {
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
border: none;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
background: var(--color-brand);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider:hover::-webkit-slider-thumb:not(.disabled) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .slider:hover::-moz-range-thumb:not(.disabled) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: var(--transition-speed);
|
||||
}
|
||||
|
||||
.slider-component .slide-container .snap-points-wrapper {
|
||||
position: absolute;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
|
||||
.snap-points {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
.snap-points {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
vertical-align: middle;
|
||||
vertical-align: middle;
|
||||
|
||||
width: calc(100% - 0.75rem);
|
||||
height: 0.75rem;
|
||||
width: calc(100% - 0.75rem);
|
||||
height: 0.75rem;
|
||||
|
||||
left: calc(0.75rem / 2);
|
||||
left: calc(0.75rem / 2);
|
||||
|
||||
.snap-point {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
.snap-point {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
background-color: var(--color-base);
|
||||
background-color: var(--color-base);
|
||||
|
||||
transform: translateX(calc(-0.25rem / 2));
|
||||
transform: translateX(calc(-0.25rem / 2));
|
||||
|
||||
&.green {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.green {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
width: 6rem;
|
||||
margin-left: 0.75rem;
|
||||
width: 6rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
<template>
|
||||
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
|
||||
<slot name="clickable" />
|
||||
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="smart-clickable" :class="{ 'smart-clickable--has-clickable': !!$slots.clickable }">
|
||||
<slot name="clickable" />
|
||||
<div v-bind="$attrs" class="smart-clickable__contents pointer-events-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.smart-clickable {
|
||||
display: grid;
|
||||
display: grid;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:allow-pointer-events) {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.smart-clickable__contents {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:allow-pointer-events) {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply effects when a clickable is present
|
||||
.smart-clickable.smart-clickable--has-clickable {
|
||||
// Setup base styles for contents
|
||||
.smart-clickable__contents {
|
||||
transition: scale 0.125s ease-out;
|
||||
// Setup base styles for contents
|
||||
.smart-clickable__contents {
|
||||
transition: scale 0.125s ease-out;
|
||||
|
||||
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
|
||||
//filter: brightness(1.00001);
|
||||
}
|
||||
// Why? I don't know. It forces the SVGs to render differently, which fixes some shift on hover otherwise.
|
||||
//filter: brightness(1.00001);
|
||||
}
|
||||
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
||||
filter: var(--hover-filter-weak);
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
&:has(> *:first-child:hover, > *:first-child:focus-visible) .smart-clickable__contents {
|
||||
filter: var(--hover-filter-weak);
|
||||
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:highlight-on-hover) {
|
||||
filter: brightness(var(--hover-brightness, 1.25));
|
||||
}
|
||||
}
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:highlight-on-hover) {
|
||||
filter: brightness(var(--hover-brightness, 1.25));
|
||||
}
|
||||
}
|
||||
|
||||
// When clickable is being clicked, give contents an effect
|
||||
&:has(> *:first-child:active) .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
}
|
||||
// When clickable is being clicked, give contents an effect
|
||||
&:has(> *:first-child:active) .smart-clickable__contents {
|
||||
scale: 0.97;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot></slot>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold">{{ value }}</span>
|
||||
<span class="text-secondary">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot></slot>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold">{{ value }}</span>
|
||||
<span class="text-secondary">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
:slotted(*) {
|
||||
@apply h-6 w-6 text-secondary;
|
||||
@apply h-6 w-6 text-secondary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +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>
|
||||
<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
|
||||
action?: (event: MouseEvent) => void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const ITEM_HEIGHT = 44
|
||||
const BUFFER_ITEMS = 5
|
||||
|
||||
interface Props {
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
options: OptionValue[]
|
||||
name: string
|
||||
defaultValue?: OptionValue | null
|
||||
placeholder?: string | number | null
|
||||
modelValue?: OptionValue | null
|
||||
renderUp?: boolean
|
||||
disabled?: boolean
|
||||
displayName?: (option: OptionValue) => string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
(e: 'input' | 'update:modelValue', value: OptionValue): void
|
||||
(e: 'change', value: { option: OptionValue; index: number }): void
|
||||
}>()
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
@@ -144,298 +144,298 @@ const virtualListHeight = ref(300)
|
||||
const lastFocusedElement = ref<HTMLElement | null>(null)
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
zIndex: 999,
|
||||
})
|
||||
|
||||
const handleOptionRef = (el: HTMLElement | null, index: number) => {
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el
|
||||
}
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = async () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
dropdownVisible.value = true
|
||||
await updatePosition()
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus()
|
||||
})
|
||||
}
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
dropdownVisible.value = true
|
||||
await updatePosition()
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown()
|
||||
}
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
let currentNode: HTMLElement | null = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
}
|
||||
}
|
||||
return null
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
|
||||
})
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue)
|
||||
}
|
||||
return props.placeholder || 'Select an option'
|
||||
})
|
||||
|
||||
const radioValue = computed<OptionValue>({
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? ''
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit('update:modelValue', newValue)
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
})
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
'cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
'cursor-not-allowed opacity-50 grayscale': props.disabled,
|
||||
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}))
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return
|
||||
if (!dropdown.value) return
|
||||
|
||||
await nextTick()
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
await nextTick()
|
||||
const triggerRect = dropdown.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const margin = 8
|
||||
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT
|
||||
const preferredHeight = Math.min(contentHeight, 300)
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom
|
||||
const spaceAbove = triggerRect.top
|
||||
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
|
||||
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight)
|
||||
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
positionStyle.value = {
|
||||
position: 'fixed',
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
|
||||
}
|
||||
}
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
await updatePosition()
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns()
|
||||
dropdownVisible.value = true
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
await updatePosition()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
const target = event.target as HTMLElement
|
||||
scrollTop.value = target.scrollTop
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
toggleDropdown()
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event)
|
||||
}
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement
|
||||
toggleDropdown()
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropdownKeyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation()
|
||||
event.stopPropagation()
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption()
|
||||
} else {
|
||||
focusNextOption()
|
||||
}
|
||||
break
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusNextOption()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusPreviousOption()
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption()
|
||||
} else {
|
||||
focusNextOption()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownVisible.value = false
|
||||
focusedOptionIndex.value = null
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus()
|
||||
lastFocusedElement.value = null
|
||||
}
|
||||
dropdownVisible.value = false
|
||||
focusedOptionIndex.value = null
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus()
|
||||
lastFocusedElement.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
const event = new CustomEvent('close-all-dropdowns')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
const selectOption = (option: OptionValue, index: number) => {
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
radioValue.value = option
|
||||
emit('change', { option, index })
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
const focusNextOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
|
||||
}
|
||||
scrollToFocused()
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToFocused = () => {
|
||||
if (focusedOptionIndex.value === null) return
|
||||
if (focusedOptionIndex.value === null) return
|
||||
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
|
||||
if (!optionsElement) return
|
||||
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
|
||||
const scrollBottom = optionsElement.clientHeight
|
||||
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleResize, true)
|
||||
window.addEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.addEventListener('close-all-dropdowns', closeDropdown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
lastFocusedElement.value = null
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleResize, true)
|
||||
window.removeEventListener('click', (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('close-all-dropdowns', closeDropdown)
|
||||
lastFocusedElement.value = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
watch(dropdownVisible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
if (newValue) {
|
||||
await updatePosition()
|
||||
scrollTop.value = 0
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
fadeOutStart?: boolean
|
||||
fadeOutEnd?: boolean
|
||||
}>(),
|
||||
{
|
||||
fadeOutStart: false,
|
||||
fadeOutEnd: false,
|
||||
},
|
||||
defineProps<{
|
||||
fadeOutStart?: boolean
|
||||
fadeOutEnd?: boolean
|
||||
}>(),
|
||||
{
|
||||
fadeOutStart: false,
|
||||
fadeOutEnd: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-4 pb-6 isolate">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div
|
||||
class="timeline-indicator"
|
||||
:class="{ 'fade-out-start': fadeOutStart, 'fade-out-end': fadeOutEnd }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
<div class="relative flex flex-col gap-4 pb-6 isolate">
|
||||
<div class="absolute flex h-full w-4 justify-center">
|
||||
<div
|
||||
class="timeline-indicator"
|
||||
:class="{ 'fade-out-start': fadeOutStart, 'fade-out-end': fadeOutEnd }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.timeline-indicator {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--timeline-line-color, var(--color-raised-bg)) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
margin-top: 1rem;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--timeline-line-color, var(--color-raised-bg)) 66%,
|
||||
rgba(255, 255, 255, 0) 0%
|
||||
);
|
||||
background-size: 100% 30px;
|
||||
background-repeat: repeat-y;
|
||||
margin-top: 1rem;
|
||||
|
||||
height: calc(100% - 1rem);
|
||||
width: 4px;
|
||||
z-index: -1;
|
||||
height: calc(100% - 1rem);
|
||||
width: 4px;
|
||||
z-index: -1;
|
||||
|
||||
&.fade-out-start {
|
||||
mask-image: linear-gradient(to top, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
&.fade-out-start {
|
||||
mask-image: linear-gradient(to top, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-end {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
&.fade-out-end {
|
||||
mask-image: linear-gradient(to bottom, black calc(100% - 15rem), transparent 100%);
|
||||
}
|
||||
|
||||
&.fade-out-start.fade-out-end {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
&.fade-out-start.fade-out-end {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black,
|
||||
black calc(100% - 8rem),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
class="switch stylized-toggle"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
@change="checked = !checked"
|
||||
/>
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
class="switch stylized-toggle"
|
||||
:disabled="disabled"
|
||||
:checked="checked"
|
||||
@change="checked = !checked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const checked = defineModel<boolean>()
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { createStripeElements } from '@modrinth/utils'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import { loadStripe, type Stripe as StripsJs, type StripeElements } from '@stripe/stripe-js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startLoading' | 'stopLoading'): void
|
||||
(e: 'startLoading' | 'stopLoading'): void
|
||||
}>()
|
||||
|
||||
export type SetupIntentResponse = {
|
||||
client_secret: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
export type AddPaymentMethodProps = {
|
||||
publishableKey: string
|
||||
createSetupIntent: () => Promise<SetupIntentResponse>
|
||||
returnUrl: string
|
||||
onError: (error: Error) => void
|
||||
publishableKey: string
|
||||
createSetupIntent: () => Promise<SetupIntentResponse>
|
||||
returnUrl: string
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
const props = defineProps<AddPaymentMethodProps>()
|
||||
@@ -27,78 +28,78 @@ const elements = ref<StripeElements>()
|
||||
const error = ref(false)
|
||||
|
||||
function handleError(error: Error) {
|
||||
props.onError(error)
|
||||
error.value = true
|
||||
props.onError(error)
|
||||
error.value = true
|
||||
}
|
||||
|
||||
async function reload(paymentMethods: Stripe.PaymentMethod[]) {
|
||||
try {
|
||||
elementsLoaded.value = 0
|
||||
error.value = false
|
||||
try {
|
||||
elementsLoaded.value = 0
|
||||
error.value = false
|
||||
|
||||
const result = await props.createSetupIntent()
|
||||
const result = await props.createSetupIntent()
|
||||
|
||||
stripe.value = await loadStripe(props.publishableKey)
|
||||
const {
|
||||
elements: newElements,
|
||||
addressElement,
|
||||
paymentElement,
|
||||
} = createStripeElements(stripe.value, paymentMethods, {
|
||||
clientSecret: result.client_secret,
|
||||
})
|
||||
stripe.value = await loadStripe(props.publishableKey)
|
||||
const {
|
||||
elements: newElements,
|
||||
addressElement,
|
||||
paymentElement,
|
||||
} = createStripeElements(stripe.value, paymentMethods, {
|
||||
clientSecret: result.client_secret,
|
||||
})
|
||||
|
||||
elements.value = newElements
|
||||
paymentElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
addressElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
elements.value = newElements
|
||||
paymentElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
addressElement.on('ready', () => {
|
||||
elementsLoaded.value += 1
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
emit('startLoading')
|
||||
emit('startLoading')
|
||||
|
||||
const result = await stripe.value.confirmSetup({
|
||||
elements: elements.value,
|
||||
confirmParams: {
|
||||
return_url: props.returnUrl,
|
||||
},
|
||||
})
|
||||
const result = await stripe.value.confirmSetup({
|
||||
elements: elements.value,
|
||||
confirmParams: {
|
||||
return_url: props.returnUrl,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
console.log(result)
|
||||
|
||||
const { error } = result
|
||||
const { error } = result
|
||||
|
||||
emit('stopLoading')
|
||||
if (error && error.type !== 'validation_error') {
|
||||
handleError(error.message)
|
||||
return false
|
||||
} else if (!error) {
|
||||
return true
|
||||
}
|
||||
emit('stopLoading')
|
||||
if (error && error.type !== 'validation_error') {
|
||||
handleError(error.message)
|
||||
return false
|
||||
} else if (!error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reload,
|
||||
submit,
|
||||
reload,
|
||||
submit,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
|
||||
<div v-show="elementsLoaded < 2">
|
||||
<ModalLoadingIndicator :error="error">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-[16rem] flex flex-col gap-2 justify-center items-center">
|
||||
<div v-show="elementsLoaded < 2">
|
||||
<ModalLoadingIndicator :error="error">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from '../index'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import AddPaymentMethod from './AddPaymentMethod.vue'
|
||||
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
|
||||
import { commonMessages } from '../../utils'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
import { commonMessages } from '../../utils'
|
||||
import { ButtonStyled, NewModal } from '../index'
|
||||
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'
|
||||
import AddPaymentMethod from './AddPaymentMethod.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -15,58 +16,58 @@ const props = defineProps<AddPaymentMethodProps>()
|
||||
const loading = ref(false)
|
||||
|
||||
async function open(paymentMethods: Stripe.PaymentMethod[]) {
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
await addPaymentMethod.value?.reload(paymentMethods)
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
await addPaymentMethod.value?.reload(paymentMethods)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
addingPaymentMethod: {
|
||||
id: 'modal.add-payment-method.title',
|
||||
defaultMessage: 'Adding a payment method',
|
||||
},
|
||||
paymentMethodAdd: {
|
||||
id: 'modal.add-payment-method.action',
|
||||
defaultMessage: 'Add payment method',
|
||||
},
|
||||
addingPaymentMethod: {
|
||||
id: 'modal.add-payment-method.title',
|
||||
defaultMessage: 'Adding a payment method',
|
||||
},
|
||||
paymentMethodAdd: {
|
||||
id: 'modal.add-payment-method.action',
|
||||
defaultMessage: 'Add payment method',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: open,
|
||||
show: open,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.addingPaymentMethod) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<AddPaymentMethod
|
||||
ref="addPaymentMethod"
|
||||
:publishable-key="props.publishableKey"
|
||||
:return-url="props.returnUrl"
|
||||
:create-setup-intent="props.createSetupIntent"
|
||||
:on-error="props.onError"
|
||||
@start-loading="loading = true"
|
||||
@stop-loading="loading = false"
|
||||
/>
|
||||
<div class="input-group mt-auto pt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading" @click="addPaymentMethod.submit()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.paymentMethodAdd) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.addingPaymentMethod) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<AddPaymentMethod
|
||||
ref="addPaymentMethod"
|
||||
:publishable-key="props.publishableKey"
|
||||
:return-url="props.returnUrl"
|
||||
:create-setup-intent="props.createSetupIntent"
|
||||
:on-error="props.onError"
|
||||
@start-loading="loading = true"
|
||||
@stop-loading="loading = false"
|
||||
/>
|
||||
<div class="input-group mt-auto pt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading" @click="addPaymentMethod.submit()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.paymentMethodAdd) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -1,65 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
|
||||
const { locale } = useVIntl()
|
||||
|
||||
export type BillingItem = {
|
||||
title: string
|
||||
amount: number
|
||||
title: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
period?: string
|
||||
currency: string
|
||||
total: number
|
||||
billingItems: BillingItem[]
|
||||
loading?: boolean
|
||||
period?: string
|
||||
currency: string
|
||||
total: number
|
||||
billingItems: BillingItem[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const periodSuffix = computed(() => {
|
||||
return props.period ? ` / ${props.period}` : ''
|
||||
return props.period ? ` / ${props.period}` : ''
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Accordion
|
||||
class="rounded-2xl overflow-hidden bg-bg"
|
||||
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
|
||||
>
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
|
||||
<div class="text-right mr-1">
|
||||
<span class="text-primary font-bold">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, total, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
|
||||
<div
|
||||
v-for="{ title, amount } in billingItems"
|
||||
:key="title"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
<Accordion
|
||||
class="rounded-2xl overflow-hidden bg-bg"
|
||||
button-class="bg-transparent p-0 w-full p-4 active:scale-[0.98] transition-transform duration-100"
|
||||
>
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-contrast font-bold">Total</div>
|
||||
<div class="text-right mr-1">
|
||||
<span class="text-primary font-bold">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, total, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 flex flex-col gap-4 bg-table-alternateRow">
|
||||
<div
|
||||
v-for="{ title, amount } in billingItems"
|
||||
:key="title"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<template v-if="loading">
|
||||
<SpinnerIcon class="animate-spin size-4" />
|
||||
</template>
|
||||
<template v-else> {{ formatPrice(locale, amount, currency) }} </template
|
||||
><span class="text-xs text-secondary">{{ periodSuffix }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { CardIcon, CurrencyIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { commonMessages, paymentMethodMessages } from '../../utils'
|
||||
import type Stripe from 'stripe'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import { commonMessages, paymentMethodMessages } from '../../utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
defineProps<{
|
||||
method: Stripe.PaymentMethod
|
||||
method: Stripe.PaymentMethod
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="'type' in method">
|
||||
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
|
||||
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
|
||||
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
|
||||
<UnknownIcon v-else class="size-[1.5em]" />
|
||||
<span v-if="method.type === 'card' && 'card' in method && method.card">
|
||||
{{
|
||||
formatMessage(commonMessages.paymentMethodCardDisplay, {
|
||||
card_brand:
|
||||
formatMessage(paymentMethodMessages[method.card.brand]) ??
|
||||
formatMessage(paymentMethodMessages.unknown),
|
||||
last_four: method.card.last4,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(paymentMethodMessages[method.type]) ??
|
||||
formatMessage(paymentMethodMessages.unknown)
|
||||
}}
|
||||
</template>
|
||||
<template v-if="'type' in method">
|
||||
<CardIcon v-if="method.type === 'card'" class="size-[1.5em]" />
|
||||
<CurrencyIcon v-else-if="method.type === 'cashapp'" class="size-[1.5em]" />
|
||||
<PayPalIcon v-else-if="method.type === 'paypal'" class="size-[1.5em]" />
|
||||
<UnknownIcon v-else class="size-[1.5em]" />
|
||||
<span v-if="method.type === 'card' && 'card' in method && method.card">
|
||||
{{
|
||||
formatMessage(commonMessages.paymentMethodCardDisplay, {
|
||||
card_brand:
|
||||
formatMessage(paymentMethodMessages[method.card.brand]) ??
|
||||
formatMessage(paymentMethodMessages.unknown),
|
||||
last_four: method.card.last4,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-else>
|
||||
{{
|
||||
formatMessage(paymentMethodMessages[method.type]) ??
|
||||
formatMessage(paymentMethodMessages.unknown)
|
||||
}}
|
||||
</template>
|
||||
|
||||
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
|
||||
({{ method.cashapp.cashtag }})
|
||||
</span>
|
||||
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
|
||||
({{ method.paypal.payer_email }})
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="method.type === 'cashapp' && 'cashapp' in method && method.cashapp">
|
||||
({{ method.cashapp.cashtag }})
|
||||
</span>
|
||||
<span v-else-if="method.type === 'paypal' && 'paypal' in method && method.paypal">
|
||||
({{ method.paypal.payer_email }})
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, useTemplateRef, nextTick, watch } from 'vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import { type MessageDescriptor, useVIntl, defineMessage } from '@vintl/vintl'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
SpinnerIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import { commonMessages } from '../../utils'
|
||||
import type {
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
ServerRegion,
|
||||
ServerStockRequest,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
ServerRegion,
|
||||
ServerStockRequest,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../../utils/billing'
|
||||
import { ButtonStyled } from '../index'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import { commonMessages } from '../../utils'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import RegionSelector from './ServersPurchase1Region.vue'
|
||||
import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
|
||||
import ConfirmPurchase from './ServersPurchase3Review.vue'
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type RegionPing = {
|
||||
region: string
|
||||
ping: number
|
||||
region: string
|
||||
ping: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
|
||||
onError: (err: Error) => void
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse>
|
||||
onError: (err: Error) => void
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
@@ -62,33 +62,33 @@ const selectedRegion = ref<string>()
|
||||
const projectId = ref<string>()
|
||||
|
||||
const {
|
||||
initializeStripe,
|
||||
selectPaymentMethod,
|
||||
primaryPaymentMethodId,
|
||||
loadStripeElements,
|
||||
selectedPaymentMethod,
|
||||
inputtedPaymentMethod,
|
||||
createNewPaymentMethod,
|
||||
loadingElements,
|
||||
loadingElementsFailed,
|
||||
tax,
|
||||
total,
|
||||
paymentMethodLoading,
|
||||
reloadPaymentIntent,
|
||||
hasPaymentMethod,
|
||||
submitPayment,
|
||||
completingPurchase,
|
||||
initializeStripe,
|
||||
selectPaymentMethod,
|
||||
primaryPaymentMethodId,
|
||||
loadStripeElements,
|
||||
selectedPaymentMethod,
|
||||
inputtedPaymentMethod,
|
||||
createNewPaymentMethod,
|
||||
loadingElements,
|
||||
loadingElementsFailed,
|
||||
tax,
|
||||
total,
|
||||
paymentMethodLoading,
|
||||
reloadPaymentIntent,
|
||||
hasPaymentMethod,
|
||||
submitPayment,
|
||||
completingPurchase,
|
||||
} = useStripe(
|
||||
props.publishableKey,
|
||||
props.customer,
|
||||
props.paymentMethods,
|
||||
props.currency,
|
||||
selectedPlan,
|
||||
selectedInterval,
|
||||
selectedRegion,
|
||||
projectId,
|
||||
props.initiatePayment,
|
||||
props.onError,
|
||||
props.publishableKey,
|
||||
props.customer,
|
||||
props.paymentMethods,
|
||||
props.currency,
|
||||
selectedPlan,
|
||||
selectedInterval,
|
||||
selectedRegion,
|
||||
projectId,
|
||||
props.initiatePayment,
|
||||
props.onError,
|
||||
)
|
||||
|
||||
const customServer = ref<boolean>(false)
|
||||
@@ -100,252 +100,252 @@ type Step = 'region' | 'payment' | 'review'
|
||||
const steps: Step[] = ['region', 'payment', 'review']
|
||||
|
||||
const titles: Record<Step, MessageDescriptor> = {
|
||||
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
||||
payment: defineMessage({
|
||||
id: 'servers.purchase.step.payment.title',
|
||||
defaultMessage: 'Payment method',
|
||||
}),
|
||||
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
|
||||
region: defineMessage({ id: 'servers.purchase.step.region.title', defaultMessage: 'Region' }),
|
||||
payment: defineMessage({
|
||||
id: 'servers.purchase.step.payment.title',
|
||||
defaultMessage: 'Payment method',
|
||||
}),
|
||||
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
|
||||
}
|
||||
|
||||
const currentRegion = computed(() => {
|
||||
return props.regions.find((region) => region.shortcode === selectedRegion.value)
|
||||
return props.regions.find((region) => region.shortcode === selectedRegion.value)
|
||||
})
|
||||
|
||||
const currentPing = computed(() => {
|
||||
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
|
||||
return props.pings.find((ping) => ping.region === currentRegion.value?.shortcode)?.ping
|
||||
})
|
||||
|
||||
const currentStep = ref<Step>()
|
||||
|
||||
const currentStepIndex = computed(() => (currentStep.value ? steps.indexOf(currentStep.value) : -1))
|
||||
const previousStep = computed(() => {
|
||||
const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined
|
||||
if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) {
|
||||
return 'region'
|
||||
}
|
||||
return step
|
||||
const step = currentStep.value ? steps[steps.indexOf(currentStep.value) - 1] : undefined
|
||||
if (step === 'payment' && skipPaymentMethods.value && primaryPaymentMethodId.value) {
|
||||
return 'region'
|
||||
}
|
||||
return step
|
||||
})
|
||||
const nextStep = computed(() =>
|
||||
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
|
||||
currentStep.value ? steps[steps.indexOf(currentStep.value) + 1] : undefined,
|
||||
)
|
||||
|
||||
const canProceed = computed(() => {
|
||||
switch (currentStep.value) {
|
||||
case 'region':
|
||||
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
||||
case 'payment':
|
||||
return selectedPaymentMethod.value || !loadingElements.value
|
||||
case 'review':
|
||||
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
switch (currentStep.value) {
|
||||
case 'region':
|
||||
return selectedRegion.value && selectedPlan.value && selectedInterval.value
|
||||
case 'payment':
|
||||
return selectedPaymentMethod.value || !loadingElements.value
|
||||
case 'review':
|
||||
return acceptedEula.value && hasPaymentMethod.value && !completingPurchase.value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
async function beforeProceed(step: string) {
|
||||
switch (step) {
|
||||
case 'region':
|
||||
return true
|
||||
case 'payment':
|
||||
await initializeStripe()
|
||||
switch (step) {
|
||||
case 'region':
|
||||
return true
|
||||
case 'payment':
|
||||
await initializeStripe()
|
||||
|
||||
if (primaryPaymentMethodId.value && skipPaymentMethods.value) {
|
||||
const paymentMethod = await props.paymentMethods.find(
|
||||
(x) => x.id === primaryPaymentMethodId.value,
|
||||
)
|
||||
await selectPaymentMethod(paymentMethod)
|
||||
await setStep('review', true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 'review':
|
||||
if (selectedPaymentMethod.value) {
|
||||
return true
|
||||
} else {
|
||||
const token = await createNewPaymentMethod()
|
||||
return !!token
|
||||
}
|
||||
}
|
||||
if (primaryPaymentMethodId.value && skipPaymentMethods.value) {
|
||||
const paymentMethod = await props.paymentMethods.find(
|
||||
(x) => x.id === primaryPaymentMethodId.value,
|
||||
)
|
||||
await selectPaymentMethod(paymentMethod)
|
||||
await setStep('review', true)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case 'review':
|
||||
if (selectedPaymentMethod.value) {
|
||||
return true
|
||||
} else {
|
||||
const token = await createNewPaymentMethod()
|
||||
return !!token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function afterProceed(step: string) {
|
||||
switch (step) {
|
||||
case 'region':
|
||||
break
|
||||
case 'payment':
|
||||
await loadStripeElements()
|
||||
break
|
||||
case 'review':
|
||||
break
|
||||
}
|
||||
switch (step) {
|
||||
case 'region':
|
||||
break
|
||||
case 'payment':
|
||||
await loadStripeElements()
|
||||
break
|
||||
case 'review':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function setStep(step: Step | undefined, skipValidation = false) {
|
||||
if (!step) {
|
||||
await submitPayment(props.returnUrl)
|
||||
return
|
||||
}
|
||||
if (!step) {
|
||||
await submitPayment(props.returnUrl)
|
||||
return
|
||||
}
|
||||
|
||||
if (!skipValidation && !canProceed.value) {
|
||||
return
|
||||
}
|
||||
if (!skipValidation && !canProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await beforeProceed(step)) {
|
||||
currentStep.value = step
|
||||
await nextTick()
|
||||
if (await beforeProceed(step)) {
|
||||
currentStep.value = step
|
||||
await nextTick()
|
||||
|
||||
await afterProceed(step)
|
||||
}
|
||||
await afterProceed(step)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedPlan, () => {
|
||||
console.log(selectedPlan.value)
|
||||
console.log(selectedPlan.value)
|
||||
})
|
||||
|
||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
||||
loading.value = false
|
||||
selectedPlan.value = plan
|
||||
selectedInterval.value = interval
|
||||
customServer.value = !selectedPlan.value
|
||||
selectedPaymentMethod.value = undefined
|
||||
currentStep.value = steps[0]
|
||||
skipPaymentMethods.value = true
|
||||
projectId.value = project
|
||||
modal.value?.show()
|
||||
loading.value = false
|
||||
selectedPlan.value = plan
|
||||
selectedInterval.value = interval
|
||||
customServer.value = !selectedPlan.value
|
||||
selectedPaymentMethod.value = undefined
|
||||
currentStep.value = steps[0]
|
||||
skipPaymentMethods.value = true
|
||||
projectId.value = project
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: begin,
|
||||
show: begin,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||
<template v-for="(title, id, index) in titles" :key="id">
|
||||
<button
|
||||
v-if="index < currentStepIndex"
|
||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
||||
@click="setStep(id, true)"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
:class="{
|
||||
'text-contrast': index === currentStepIndex,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < steps.length - 1"
|
||||
class="h-5 w-5 text-secondary"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<RegionSelector
|
||||
v-if="currentStep === 'region'"
|
||||
v-model:region="selectedRegion"
|
||||
v-model:plan="selectedPlan"
|
||||
:regions="regions"
|
||||
:pings="pings"
|
||||
:custom="customServer"
|
||||
:available-products="availableProducts"
|
||||
:currency="currency"
|
||||
:interval="selectedInterval"
|
||||
:fetch-stock="fetchStock"
|
||||
/>
|
||||
<PaymentMethodSelector
|
||||
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
|
||||
:payment-methods="paymentMethods"
|
||||
:selected="selectedPaymentMethod"
|
||||
:loading-elements="loadingElements"
|
||||
:loading-elements-failed="loadingElementsFailed"
|
||||
@select="selectPaymentMethod"
|
||||
/>
|
||||
<ConfirmPurchase
|
||||
v-else-if="
|
||||
currentStep === 'review' &&
|
||||
hasPaymentMethod &&
|
||||
currentRegion &&
|
||||
selectedInterval &&
|
||||
selectedPlan
|
||||
"
|
||||
v-model:interval="selectedInterval"
|
||||
v-model:accepted-eula="acceptedEula"
|
||||
:currency="currency"
|
||||
:plan="selectedPlan"
|
||||
:region="currentRegion"
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
@change-payment-method="
|
||||
() => {
|
||||
skipPaymentMethods = false
|
||||
setStep('payment', true)
|
||||
}
|
||||
"
|
||||
@reload-payment-intent="reloadPaymentIntent"
|
||||
/>
|
||||
<div v-else>Something went wrong</div>
|
||||
<div
|
||||
v-show="
|
||||
selectedPaymentMethod === undefined &&
|
||||
currentStep === 'payment' &&
|
||||
selectedPlan &&
|
||||
selectedInterval
|
||||
"
|
||||
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
|
||||
>
|
||||
<div v-show="loadingElements">
|
||||
<ModalLoadingIndicator :error="loadingElementsFailed">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-between mt-4">
|
||||
<ButtonStyled>
|
||||
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
|
||||
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
|
||||
</button>
|
||||
<button v-else @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
currentStep === 'review' && !acceptedEula
|
||||
? 'You must accept the Minecraft EULA to proceed.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!canProceed"
|
||||
@click="setStep(nextStep)"
|
||||
>
|
||||
<template v-if="currentStep === 'review'">
|
||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||
<CheckCircleIcon v-else />
|
||||
Subscribe
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-1 font-bold text-secondary">
|
||||
<template v-for="(title, id, index) in titles" :key="id">
|
||||
<button
|
||||
v-if="index < currentStepIndex"
|
||||
class="bg-transparent active:scale-95 font-bold text-secondary p-0"
|
||||
@click="setStep(id, true)"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
:class="{
|
||||
'text-contrast': index === currentStepIndex,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(title) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < steps.length - 1"
|
||||
class="h-5 w-5 text-secondary"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-[40rem] max-w-full">
|
||||
<RegionSelector
|
||||
v-if="currentStep === 'region'"
|
||||
v-model:region="selectedRegion"
|
||||
v-model:plan="selectedPlan"
|
||||
:regions="regions"
|
||||
:pings="pings"
|
||||
:custom="customServer"
|
||||
:available-products="availableProducts"
|
||||
:currency="currency"
|
||||
:interval="selectedInterval"
|
||||
:fetch-stock="fetchStock"
|
||||
/>
|
||||
<PaymentMethodSelector
|
||||
v-else-if="currentStep === 'payment' && selectedPlan && selectedInterval"
|
||||
:payment-methods="paymentMethods"
|
||||
:selected="selectedPaymentMethod"
|
||||
:loading-elements="loadingElements"
|
||||
:loading-elements-failed="loadingElementsFailed"
|
||||
@select="selectPaymentMethod"
|
||||
/>
|
||||
<ConfirmPurchase
|
||||
v-else-if="
|
||||
currentStep === 'review' &&
|
||||
hasPaymentMethod &&
|
||||
currentRegion &&
|
||||
selectedInterval &&
|
||||
selectedPlan
|
||||
"
|
||||
v-model:interval="selectedInterval"
|
||||
v-model:accepted-eula="acceptedEula"
|
||||
:currency="currency"
|
||||
:plan="selectedPlan"
|
||||
:region="currentRegion"
|
||||
:ping="currentPing"
|
||||
:loading="paymentMethodLoading"
|
||||
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
|
||||
:tax="tax"
|
||||
:total="total"
|
||||
@change-payment-method="
|
||||
() => {
|
||||
skipPaymentMethods = false
|
||||
setStep('payment', true)
|
||||
}
|
||||
"
|
||||
@reload-payment-intent="reloadPaymentIntent"
|
||||
/>
|
||||
<div v-else>Something went wrong</div>
|
||||
<div
|
||||
v-show="
|
||||
selectedPaymentMethod === undefined &&
|
||||
currentStep === 'payment' &&
|
||||
selectedPlan &&
|
||||
selectedInterval
|
||||
"
|
||||
class="min-h-[16rem] flex flex-col gap-2 mt-2 p-4 bg-table-alternateRow rounded-xl justify-center items-center"
|
||||
>
|
||||
<div v-show="loadingElements">
|
||||
<ModalLoadingIndicator :error="loadingElementsFailed">
|
||||
Loading...
|
||||
<template #error> Error loading Stripe payment UI. </template>
|
||||
</ModalLoadingIndicator>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-between mt-4">
|
||||
<ButtonStyled>
|
||||
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
|
||||
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
|
||||
</button>
|
||||
<button v-else @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
currentStep === 'review' && !acceptedEula
|
||||
? 'You must accept the Minecraft EULA to proceed.'
|
||||
: undefined
|
||||
"
|
||||
:disabled="!canProceed"
|
||||
@click="setStep(nextStep)"
|
||||
>
|
||||
<template v-if="currentStep === 'review'">
|
||||
<SpinnerIcon v-if="completingPurchase" class="animate-spin" />
|
||||
<CheckCircleIcon v-else />
|
||||
Subscribe
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(commonMessages.nextButton) }} <RightArrowIcon />
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { RadioButtonIcon, RadioButtonCheckedIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { RadioButtonCheckedIcon, RadioButtonIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: Stripe.PaymentMethod | undefined
|
||||
selected: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
},
|
||||
defineProps<{
|
||||
item: Stripe.PaymentMethod | undefined
|
||||
selected: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
|
||||
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<button
|
||||
class="flex items-center w-full gap-2 border-none p-3 text-primary rounded-xl transition-all duration-200 hover:bg-button-bg hover:brightness-[--hover-brightness] active:scale-[0.98] hover:cursor-pointer"
|
||||
:class="selected ? 'bg-button-bg' : 'bg-transparent'"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="selected" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
|
||||
<template v-if="item === undefined">
|
||||
<span>New payment method</span>
|
||||
</template>
|
||||
<FormattedPaymentMethod v-else-if="item" :method="item" />
|
||||
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
|
||||
</button>
|
||||
<template v-if="item === undefined">
|
||||
<span>New payment method</span>
|
||||
</template>
|
||||
<FormattedPaymentMethod v-else-if="item" :method="item" />
|
||||
<SpinnerIcon v-if="loading" class="ml-auto size-4 text-secondary animate-spin" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import ServersRegionButton from './ServersRegionButton.vue'
|
||||
import { InfoIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
type ServerStockRequest,
|
||||
} from '../../utils/billing'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import { SpinnerIcon, XIcon, InfoIcon } from '@modrinth/assets'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { formatPrice } from '../../../../utils'
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
type ServerStockRequest,
|
||||
} from '../../utils/billing'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersRegionButton from './ServersRegionButton.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
regions: ServerRegion[]
|
||||
pings: RegionPing[]
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
custom: boolean
|
||||
currency: string
|
||||
interval: ServerBillingInterval
|
||||
availableProducts: ServerPlan[]
|
||||
regions: ServerRegion[]
|
||||
pings: RegionPing[]
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
custom: boolean
|
||||
currency: string
|
||||
interval: ServerBillingInterval
|
||||
availableProducts: ServerPlan[]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -35,232 +36,232 @@ const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedRegion = defineModel<string>('region')
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
|
||||
?.prices?.intervals?.[props.interval]
|
||||
return amount ? amount / monthsInInterval[props.interval] : undefined
|
||||
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
|
||||
?.prices?.intervals?.[props.interval]
|
||||
return amount ? amount / monthsInInterval[props.interval] : undefined
|
||||
})
|
||||
|
||||
const regionOrder: string[] = ['us-vin', 'eu-cov', 'eu-lim']
|
||||
|
||||
const sortedRegions = computed(() => {
|
||||
return props.regions.slice().sort((a, b) => {
|
||||
return regionOrder.indexOf(a.shortcode) - regionOrder.indexOf(b.shortcode)
|
||||
})
|
||||
return props.regions.slice().sort((a, b) => {
|
||||
return regionOrder.indexOf(a.shortcode) - regionOrder.indexOf(b.shortcode)
|
||||
})
|
||||
})
|
||||
|
||||
const selectedRam = ref<number>(-1)
|
||||
|
||||
const ramOptions = computed(() => {
|
||||
return props.availableProducts
|
||||
.map((product) => (product.metadata.ram ?? 0) / 1024)
|
||||
.filter((x) => x > 0)
|
||||
return props.availableProducts
|
||||
.map((product) => (product.metadata.ram ?? 0) / 1024)
|
||||
.filter((x) => x > 0)
|
||||
})
|
||||
|
||||
const minRam = computed(() => {
|
||||
return Math.min(...ramOptions.value)
|
||||
return Math.min(...ramOptions.value)
|
||||
})
|
||||
const maxRam = computed(() => {
|
||||
return Math.max(...ramOptions.value)
|
||||
return Math.max(...ramOptions.value)
|
||||
})
|
||||
|
||||
const lowestProduct = computed(() => {
|
||||
return (
|
||||
props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
|
||||
) ?? props.availableProducts[0]
|
||||
)
|
||||
return (
|
||||
props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
|
||||
) ?? props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
function updateRamStock(regionToCheck: string, newRam: number) {
|
||||
if (newRam > 0) {
|
||||
checkingCustomStock.value = true
|
||||
const plan = props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
|
||||
)
|
||||
if (plan) {
|
||||
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
|
||||
if (region) {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: plan.metadata.cpu ?? 0,
|
||||
memory_mb: plan.metadata.ram ?? 0,
|
||||
swap_mb: plan.metadata.swap ?? 0,
|
||||
storage_mb: plan.metadata.storage ?? 0,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newRam > 0) {
|
||||
checkingCustomStock.value = true
|
||||
const plan = props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
|
||||
)
|
||||
if (plan) {
|
||||
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
|
||||
if (region) {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: plan.metadata.cpu ?? 0,
|
||||
memory_mb: plan.metadata.ram ?? 0,
|
||||
swap_mb: plan.metadata.swap ?? 0,
|
||||
storage_mb: plan.metadata.storage ?? 0,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedRam, (newRam: number) => {
|
||||
if (props.custom && selectedRegion.value) {
|
||||
updateRamStock(selectedRegion.value, newRam)
|
||||
}
|
||||
if (props.custom && selectedRegion.value) {
|
||||
updateRamStock(selectedRegion.value, newRam)
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedRegion, (newRegion: string | undefined) => {
|
||||
if (props.custom && newRegion) {
|
||||
updateRamStock(newRegion, selectedRam.value)
|
||||
}
|
||||
if (props.custom && newRegion) {
|
||||
updateRamStock(newRegion, selectedRam.value)
|
||||
}
|
||||
})
|
||||
|
||||
const currentStock = ref<{ [region: string]: number }>({})
|
||||
const bestPing = ref<string>()
|
||||
|
||||
const messages = defineMessages({
|
||||
prompt: {
|
||||
id: 'servers.region.prompt',
|
||||
defaultMessage: 'Where would you like your server to be located?',
|
||||
},
|
||||
regionUnsupported: {
|
||||
id: 'servers.region.region-unsupported',
|
||||
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
|
||||
},
|
||||
customPrompt: {
|
||||
id: 'servers.region.custom.prompt',
|
||||
defaultMessage: `How much RAM do you want your server to have?`,
|
||||
},
|
||||
prompt: {
|
||||
id: 'servers.region.prompt',
|
||||
defaultMessage: 'Where would you like your server to be located?',
|
||||
},
|
||||
regionUnsupported: {
|
||||
id: 'servers.region.region-unsupported',
|
||||
defaultMessage: `Region not listed? <link>Let us know where you'd like to see Modrinth Servers next!</link>`,
|
||||
},
|
||||
customPrompt: {
|
||||
id: 'servers.region.custom.prompt',
|
||||
defaultMessage: `How much RAM do you want your server to have?`,
|
||||
},
|
||||
})
|
||||
|
||||
async function updateStock() {
|
||||
currentStock.value = {}
|
||||
const capacityChecks = sortedRegions.value.map((region) =>
|
||||
props.fetchStock(
|
||||
region,
|
||||
selectedPlan.value
|
||||
? {
|
||||
cpu: selectedPlan.value?.metadata.cpu ?? 0,
|
||||
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
|
||||
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
|
||||
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
|
||||
}
|
||||
: {
|
||||
cpu: lowestProduct.value.metadata.cpu ?? 0,
|
||||
memory_mb: lowestProduct.value.metadata.ram ?? 0,
|
||||
swap_mb: lowestProduct.value.metadata.swap ?? 0,
|
||||
storage_mb: lowestProduct.value.metadata.storage ?? 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
const results = await Promise.all(capacityChecks)
|
||||
results.forEach((result, index) => {
|
||||
currentStock.value[sortedRegions.value[index].shortcode] = result
|
||||
})
|
||||
currentStock.value = {}
|
||||
const capacityChecks = sortedRegions.value.map((region) =>
|
||||
props.fetchStock(
|
||||
region,
|
||||
selectedPlan.value
|
||||
? {
|
||||
cpu: selectedPlan.value?.metadata.cpu ?? 0,
|
||||
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
|
||||
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
|
||||
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
|
||||
}
|
||||
: {
|
||||
cpu: lowestProduct.value.metadata.cpu ?? 0,
|
||||
memory_mb: lowestProduct.value.metadata.ram ?? 0,
|
||||
swap_mb: lowestProduct.value.metadata.swap ?? 0,
|
||||
storage_mb: lowestProduct.value.metadata.storage ?? 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
const results = await Promise.all(capacityChecks)
|
||||
results.forEach((result, index) => {
|
||||
currentStock.value[sortedRegions.value[index].shortcode] = result
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// auto select region with lowest ping
|
||||
loading.value = true
|
||||
bestPing.value =
|
||||
props.pings.length > 0
|
||||
? props.pings.reduce((acc, cur) => {
|
||||
return acc.ping < cur.ping ? acc : cur
|
||||
})?.region
|
||||
: undefined
|
||||
selectedRegion.value = undefined
|
||||
selectedRam.value = minRam.value
|
||||
checkingCustomStock.value = true
|
||||
updateStock().then(() => {
|
||||
const firstWithStock = sortedRegions.value.find(
|
||||
(region) => currentStock.value[region.shortcode] > 0,
|
||||
)
|
||||
let stockedRegion = selectedRegion.value
|
||||
if (!stockedRegion) {
|
||||
stockedRegion =
|
||||
bestPing.value && currentStock.value[bestPing.value] > 0
|
||||
? bestPing.value
|
||||
: firstWithStock?.shortcode
|
||||
}
|
||||
selectedRegion.value = stockedRegion
|
||||
if (props.custom && stockedRegion) {
|
||||
updateRamStock(stockedRegion, minRam.value)
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
// auto select region with lowest ping
|
||||
loading.value = true
|
||||
bestPing.value =
|
||||
props.pings.length > 0
|
||||
? props.pings.reduce((acc, cur) => {
|
||||
return acc.ping < cur.ping ? acc : cur
|
||||
})?.region
|
||||
: undefined
|
||||
selectedRegion.value = undefined
|
||||
selectedRam.value = minRam.value
|
||||
checkingCustomStock.value = true
|
||||
updateStock().then(() => {
|
||||
const firstWithStock = sortedRegions.value.find(
|
||||
(region) => currentStock.value[region.shortcode] > 0,
|
||||
)
|
||||
let stockedRegion = selectedRegion.value
|
||||
if (!stockedRegion) {
|
||||
stockedRegion =
|
||||
bestPing.value && currentStock.value[bestPing.value] > 0
|
||||
? bestPing.value
|
||||
: firstWithStock?.shortcode
|
||||
}
|
||||
selectedRegion.value = stockedRegion
|
||||
if (props.custom && stockedRegion) {
|
||||
updateRamStock(stockedRegion, minRam.value)
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
|
||||
Checking availability...
|
||||
</ModalLoadingIndicator>
|
||||
<template v-else>
|
||||
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ServersRegionButton
|
||||
v-for="region in sortedRegions"
|
||||
:key="region.shortcode"
|
||||
v-model="selectedRegion"
|
||||
:region="region"
|
||||
:out-of-stock="currentStock[region.shortcode] === 0"
|
||||
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
|
||||
:best-ping="bestPing === region.shortcode"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-sm">
|
||||
<IntlFormatted :message-id="messages.regionUnsupported">
|
||||
<template #link="{ children }">
|
||||
<a
|
||||
class="text-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://surveys.modrinth.com/servers-region-waitlist"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<template v-if="custom">
|
||||
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.customPrompt) }}
|
||||
</h2>
|
||||
<div>
|
||||
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
|
||||
<p v-if="selectedPrice" class="mt-2 mb-0">
|
||||
<span class="text-contrast text-lg font-bold"
|
||||
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
|
||||
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
|
||||
</p>
|
||||
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary">
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
<div v-else-if="selectedPlan">
|
||||
<ServersSpecs
|
||||
class="!flex-row justify-between"
|
||||
:ram="selectedPlan.metadata.ram ?? 0"
|
||||
:storage="selectedPlan.metadata.storage ?? 0"
|
||||
:cpus="selectedPlan.metadata.cpu ?? 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
|
||||
{{ selectedRam }} GB RAM in this region.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
|
||||
<span class="text-sm text-secondary">
|
||||
Storage and shared CPU count are currently not configurable independently, and are based
|
||||
on the amount of RAM you select.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<ModalLoadingIndicator v-if="loading" class="flex py-40 justify-center">
|
||||
Checking availability...
|
||||
</ModalLoadingIndicator>
|
||||
<template v-else>
|
||||
<h2 class="mt-0 mb-4 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ServersRegionButton
|
||||
v-for="region in sortedRegions"
|
||||
:key="region.shortcode"
|
||||
v-model="selectedRegion"
|
||||
:region="region"
|
||||
:out-of-stock="currentStock[region.shortcode] === 0"
|
||||
:ping="pings.find((p) => p.region === region.shortcode)?.ping"
|
||||
:best-ping="bestPing === region.shortcode"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-3 text-sm">
|
||||
<IntlFormatted :message-id="messages.regionUnsupported">
|
||||
<template #link="{ children }">
|
||||
<a
|
||||
class="text-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://surveys.modrinth.com/servers-region-waitlist"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<template v-if="custom">
|
||||
<h2 class="mt-4 mb-2 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.customPrompt) }}
|
||||
</h2>
|
||||
<div>
|
||||
<Slider v-model="selectedRam" :min="minRam" :max="maxRam" :step="2" unit="GB" />
|
||||
<p v-if="selectedPrice" class="mt-2 mb-0">
|
||||
<span class="text-contrast text-lg font-bold"
|
||||
>{{ formatPrice(locale, selectedPrice, currency, true) }} / month</span
|
||||
><span v-if="interval !== 'monthly'">, billed {{ interval }}</span>
|
||||
</p>
|
||||
<div class="bg-bg rounded-xl p-4 mt-2 text-secondary">
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
<div v-else-if="selectedPlan">
|
||||
<ServersSpecs
|
||||
class="!flex-row justify-between"
|
||||
:ram="selectedPlan.metadata.ram ?? 0"
|
||||
:storage="selectedPlan.metadata.storage ?? 0"
|
||||
:cpus="selectedPlan.metadata.cpu ?? 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
<XIcon class="size-5 shrink-0 text-red" /> Sorry, we don't have any plans available with
|
||||
{{ selectedRam }} GB RAM in this region.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<InfoIcon class="hidden sm:block shrink-0 mt-1" />
|
||||
<span class="text-sm text-secondary">
|
||||
Storage and shared CPU count are currently not configurable independently, and are based
|
||||
on the amount of RAM you select.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,53 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import type Stripe from 'stripe'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
|
||||
import PaymentMethodOption from './PaymentMethodOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
|
||||
(e: 'select', paymentMethod: Stripe.PaymentMethod | undefined): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
selected?: Stripe.PaymentMethod
|
||||
loadingElements: boolean
|
||||
loadingElementsFailed: boolean
|
||||
paymentMethods: Stripe.PaymentMethod[]
|
||||
selected?: Stripe.PaymentMethod
|
||||
loadingElements: boolean
|
||||
loadingElementsFailed: boolean
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
prompt: {
|
||||
id: 'servers.purchase.step.payment.prompt',
|
||||
defaultMessage: 'Select a payment method',
|
||||
},
|
||||
description: {
|
||||
id: 'servers.purchase.step.payment.description',
|
||||
defaultMessage: `You won't be charged yet.`,
|
||||
},
|
||||
prompt: {
|
||||
id: 'servers.purchase.step.payment.prompt',
|
||||
defaultMessage: 'Select a payment method',
|
||||
},
|
||||
description: {
|
||||
id: 'servers.purchase.step.payment.description',
|
||||
defaultMessage: `You won't be charged yet.`,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<p class="mt-0 mb-4 text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<PaymentMethodOption
|
||||
v-for="method in paymentMethods"
|
||||
:key="method.id"
|
||||
:item="method"
|
||||
:selected="selected?.id === method.id"
|
||||
@select="emit('select', method)"
|
||||
/>
|
||||
<PaymentMethodOption
|
||||
:loading="false"
|
||||
:item="undefined"
|
||||
:selected="selected === undefined"
|
||||
@select="emit('select', undefined)"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="mt-0 mb-1 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.prompt) }}
|
||||
</h2>
|
||||
<p class="mt-0 mb-4 text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<PaymentMethodOption
|
||||
v-for="method in paymentMethods"
|
||||
:key="method.id"
|
||||
:item="method"
|
||||
:selected="selected?.id === method.id"
|
||||
@select="emit('select', method)"
|
||||
/>
|
||||
<PaymentMethodOption
|
||||
:loading="false"
|
||||
:item="undefined"
|
||||
:selected="selected === undefined"
|
||||
@select="emit('select', undefined)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,262 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
} from '../../utils/billing'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
RadioButtonCheckedIcon,
|
||||
RadioButtonIcon,
|
||||
RightArrowIcon,
|
||||
SignalIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
import {
|
||||
EditIcon,
|
||||
RightArrowIcon,
|
||||
SignalIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
RadioButtonIcon,
|
||||
RadioButtonCheckedIcon,
|
||||
ExternalIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type Stripe from 'stripe'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
} from '../../utils/billing'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { locale, formatMessage } = vintl
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
|
||||
(e: 'changePaymentMethod' | 'reloadPaymentIntent'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
plan: ServerPlan
|
||||
region: ServerRegion
|
||||
tax?: number
|
||||
total?: number
|
||||
currency: string
|
||||
ping?: number
|
||||
loading?: boolean
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
plan: ServerPlan
|
||||
region: ServerRegion
|
||||
tax?: number
|
||||
total?: number
|
||||
currency: string
|
||||
ping?: number
|
||||
loading?: boolean
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
}>()
|
||||
|
||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||
const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
|
||||
|
||||
const prices = computed(() => {
|
||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||
})
|
||||
|
||||
const planName = computed(() => {
|
||||
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
||||
const ram = props.plan.metadata.ram
|
||||
if (ram === 4096) return 'Small'
|
||||
if (ram === 6144) return 'Medium'
|
||||
if (ram === 8192) return 'Large'
|
||||
return 'Custom'
|
||||
if (!props.plan || !props.plan.metadata || props.plan.metadata.type !== 'pyro') return 'Unknown'
|
||||
const ram = props.plan.metadata.ram
|
||||
if (ram === 4096) return 'Small'
|
||||
if (ram === 6144) return 'Medium'
|
||||
if (ram === 8192) return 'Large'
|
||||
return 'Custom'
|
||||
})
|
||||
|
||||
const flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
)
|
||||
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
|
||||
const title = computed(() =>
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
)
|
||||
const locationSubtitle = computed(() =>
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
)
|
||||
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
|
||||
|
||||
const period = computed(() => {
|
||||
if (interval.value === 'monthly') return 'month'
|
||||
if (interval.value === 'quarterly') return '3 months'
|
||||
if (interval.value === 'yearly') return 'year'
|
||||
return '???'
|
||||
if (interval.value === 'monthly') return 'month'
|
||||
if (interval.value === 'quarterly') return '3 months'
|
||||
if (interval.value === 'yearly') return 'year'
|
||||
return '???'
|
||||
})
|
||||
|
||||
function setInterval(newInterval: ServerBillingInterval) {
|
||||
interval.value = newInterval
|
||||
emit('reloadPaymentIntent')
|
||||
interval.value = newInterval
|
||||
emit('reloadPaymentIntent')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
|
||||
<div class="bg-table-alternateRow p-4 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
|
||||
<TagItem>{{ planName }}</TagItem>
|
||||
</div>
|
||||
<div>
|
||||
<ServersSpecs
|
||||
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
|
||||
class="!grid sm:grid-cols-2"
|
||||
:ram="plan.metadata.ram"
|
||||
:storage="plan.metadata.storage"
|
||||
:cpus="plan.metadata.cpu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="font-semibold">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
|
||||
<div class="bg-table-alternateRow p-4 rounded-2xl">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<LazyUiServersModrinthServersIcon class="flex h-5 w-fit" />
|
||||
<TagItem>{{ planName }}</TagItem>
|
||||
</div>
|
||||
<div>
|
||||
<ServersSpecs
|
||||
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
|
||||
class="!grid sm:grid-cols-2"
|
||||
:ram="plan.metadata.ram"
|
||||
:storage="plan.metadata.storage"
|
||||
:cpus="plan.metadata.cpu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="font-semibold">
|
||||
{{ title }}
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
:class="
|
||||
interval === 'monthly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('monthly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
|
||||
>Pay monthly</span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
|
||||
month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
interval === 'quarterly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('quarterly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'quarterly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'quarterly' }"
|
||||
>Pay quarterly
|
||||
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
|
||||
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
|
||||
></span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{
|
||||
formatPrice(
|
||||
locale,
|
||||
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
|
||||
currency,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/ month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ExpandableInvoiceTotal
|
||||
:period="period"
|
||||
:currency="currency"
|
||||
:loading="loading"
|
||||
:total="total ?? -1"
|
||||
:billing-items="
|
||||
total !== undefined && tax !== undefined
|
||||
? [
|
||||
{
|
||||
title: `Modrinth Servers (${planName})`,
|
||||
amount: total - tax,
|
||||
},
|
||||
{
|
||||
title: 'Tax',
|
||||
amount: tax,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
|
||||
<template v-if="selectedPaymentMethod">
|
||||
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 text-red">
|
||||
<XIcon />
|
||||
No payment method selected
|
||||
</div>
|
||||
</template>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
||||
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
|
||||
<template v-else> Select payment method <RightArrowIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 mt-4 text-sm text-secondary">
|
||||
<span class="font-semibold"
|
||||
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
||||
>
|
||||
<br />
|
||||
You'll be charged
|
||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||
formatPrice(locale, total, currency)
|
||||
}}</template>
|
||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-1 text-sm">
|
||||
<Checkbox
|
||||
v-model="acceptedEula"
|
||||
label="I acknowledge that I have read and agree to the"
|
||||
description="I acknowledge that I have read and agree to the Minecraft EULA"
|
||||
/>
|
||||
<a
|
||||
href="https://www.minecraft.net/en-us/eula"
|
||||
target="_blank"
|
||||
class="text-brand underline hover:brightness-[--hover-brightness]"
|
||||
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
|
||||
/></a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
:class="
|
||||
interval === 'monthly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('monthly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'monthly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'monthly' }"
|
||||
>Pay monthly</span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
|
||||
month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
interval === 'quarterly'
|
||||
? 'bg-button-bg border-transparent'
|
||||
: 'bg-transparent border-button-border'
|
||||
"
|
||||
class="rounded-2xl active:scale-[0.98] transition-transform duration-100 border-2 border-solid p-4 flex items-center gap-2"
|
||||
@click="setInterval('quarterly')"
|
||||
>
|
||||
<RadioButtonCheckedIcon v-if="interval === 'quarterly'" class="size-6 text-brand" />
|
||||
<RadioButtonIcon v-else class="size-6 text-secondary" />
|
||||
<div class="flex flex-col items-start gap-1 font-medium text-primary">
|
||||
<span class="flex items-center gap-1" :class="{ 'text-contrast': interval === 'quarterly' }"
|
||||
>Pay quarterly
|
||||
<span class="text-xs font-bold text-brand px-1.5 py-0.5 rounded-full bg-brand-highlight"
|
||||
>{{ interval === 'quarterly' ? 'Saving' : 'Save' }} 16%</span
|
||||
></span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{
|
||||
formatPrice(
|
||||
locale,
|
||||
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
|
||||
currency,
|
||||
true,
|
||||
)
|
||||
}}
|
||||
/ month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<ExpandableInvoiceTotal
|
||||
:period="period"
|
||||
:currency="currency"
|
||||
:loading="loading"
|
||||
:total="total ?? -1"
|
||||
:billing-items="
|
||||
total !== undefined && tax !== undefined
|
||||
? [
|
||||
{
|
||||
title: `Modrinth Servers (${planName})`,
|
||||
amount: total - tax,
|
||||
},
|
||||
{
|
||||
title: 'Tax',
|
||||
amount: tax,
|
||||
},
|
||||
]
|
||||
: []
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center pl-4 pr-2 py-3 bg-bg rounded-2xl gap-2 text-secondary">
|
||||
<template v-if="selectedPaymentMethod">
|
||||
<FormattedPaymentMethod :method="selectedPaymentMethod" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 text-red">
|
||||
<XIcon />
|
||||
No payment method selected
|
||||
</div>
|
||||
</template>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button class="ml-auto" @click="emit('changePaymentMethod')">
|
||||
<template v-if="selectedPaymentMethod"> <EditIcon /> Change </template>
|
||||
<template v-else> Select payment method <RightArrowIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 mt-4 text-sm text-secondary">
|
||||
<span class="font-semibold"
|
||||
>By clicking "Subscribe", you are purchasing a recurring subscription.</span
|
||||
>
|
||||
<br />
|
||||
You'll be charged
|
||||
<SpinnerIcon v-if="loading" class="animate-spin relative top-0.5 mx-2" /><template v-else>{{
|
||||
formatPrice(locale, total, currency)
|
||||
}}</template>
|
||||
every {{ period }} plus applicable taxes starting today, until you cancel. You can cancel
|
||||
anytime from your settings page.
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-1 text-sm">
|
||||
<Checkbox
|
||||
v-model="acceptedEula"
|
||||
label="I acknowledge that I have read and agree to the"
|
||||
description="I acknowledge that I have read and agree to the Minecraft EULA"
|
||||
/>
|
||||
<a
|
||||
href="https://www.minecraft.net/en-us/eula"
|
||||
target="_blank"
|
||||
class="text-brand underline hover:brightness-[--hover-brightness]"
|
||||
>Minecraft EULA<ExternalIcon class="size-3 shrink-0 ml-0.5 mb-0.5"
|
||||
/></a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { getPingLevel } from '@modrinth/utils'
|
||||
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ServerRegion } from '../../utils/billing'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
|
||||
@@ -11,80 +12,81 @@ const { formatMessage } = useVIntl()
|
||||
const currentRegion = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
region: ServerRegion
|
||||
ping?: number
|
||||
bestPing?: boolean
|
||||
outOfStock?: boolean
|
||||
region: ServerRegion
|
||||
ping?: number
|
||||
bestPing?: boolean
|
||||
outOfStock?: boolean
|
||||
}>()
|
||||
|
||||
const isCurrentRegion = computed(() => currentRegion.value === props.region.shortcode)
|
||||
const flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
`https://flagcdn.com/${props.region.country_code}.svg`,
|
||||
)
|
||||
const overrideTitle = computed(() => regionOverrides[props.region.shortcode]?.name)
|
||||
const title = computed(() =>
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
overrideTitle.value ? formatMessage(overrideTitle.value) : props.region.display_name,
|
||||
)
|
||||
const locationSubtitle = computed(() =>
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
overrideTitle.value ? props.region.display_name : undefined,
|
||||
)
|
||||
const pingLevel = computed(() => getPingLevel(props.ping ?? 0))
|
||||
|
||||
function setRegion() {
|
||||
currentRegion.value = props.region.shortcode
|
||||
currentRegion.value = props.region.shortcode
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:disabled="outOfStock"
|
||||
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
|
||||
:class="{
|
||||
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
|
||||
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
|
||||
'opacity-50 cursor-not-allowed': outOfStock,
|
||||
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
|
||||
!outOfStock,
|
||||
}"
|
||||
@click="setRegion"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
|
||||
:class="[
|
||||
isCurrentRegion ? 'border-brand' : 'border-button-border',
|
||||
{ 'saturate-[0.25]': outOfStock },
|
||||
]"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex flex-col gap-1 items-center">
|
||||
<span class="flex items-center gap-1 flex-wrap justify-center">
|
||||
{{ title }} <span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
:disabled="outOfStock"
|
||||
class="rounded-2xl p-4 font-semibold transition-all border-2 border-solid flex flex-col items-center gap-3"
|
||||
:class="{
|
||||
'bg-button-bg border-transparent text-primary': !isCurrentRegion,
|
||||
'bg-brand-highlight border-brand text-contrast': isCurrentRegion,
|
||||
'opacity-50 cursor-not-allowed': outOfStock,
|
||||
'hover:text-contrast active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] ':
|
||||
!outOfStock,
|
||||
}"
|
||||
@click="setRegion"
|
||||
>
|
||||
<img
|
||||
v-if="flag"
|
||||
class="aspect-[16/10] max-w-16 w-full object-cover rounded-md border-1 border-solid"
|
||||
:class="[
|
||||
isCurrentRegion ? 'border-brand' : 'border-button-border',
|
||||
{ 'saturate-[0.25]': outOfStock },
|
||||
]"
|
||||
:src="flag"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="flex flex-col gap-1 items-center">
|
||||
<span class="flex items-center gap-1 flex-wrap justify-center">
|
||||
{{ title }}
|
||||
<span v-if="outOfStock" class="text-sm text-secondary">(Out of stock)</span>
|
||||
</span>
|
||||
<span class="text-xs flex items-center gap-1 text-secondary font-medium">
|
||||
<template v-if="locationSubtitle">
|
||||
<span>
|
||||
{{ locationSubtitle }}
|
||||
</span>
|
||||
<span v-if="ping !== -1">•</span>
|
||||
</template>
|
||||
<template v-if="ping !== -1">
|
||||
<SignalIcon
|
||||
v-if="ping"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${pingLevel}: ${pingLevel <= 2 ? 'var(--color-red)' : pingLevel <= 4 ? 'var(--color-orange)' : 'var(--color-green)'}`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<template v-if="ping"> {{ ping }}ms </template>
|
||||
<span v-else> Testing connection... </span>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, MemoryStickIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import { MemoryStickIcon, DatabaseIcon, CpuIcon, SparklesIcon, UnknownIcon } from '@modrinth/assets'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click-bursting-link'): void
|
||||
(e: 'click-bursting-link'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
ram: number
|
||||
storage: number
|
||||
cpus: number
|
||||
burstingLink?: string
|
||||
}>(),
|
||||
{
|
||||
burstingLink: undefined,
|
||||
},
|
||||
defineProps<{
|
||||
ram: number
|
||||
storage: number
|
||||
cpus: number
|
||||
burstingLink?: string
|
||||
}>(),
|
||||
{
|
||||
burstingLink: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024
|
||||
return props.ram / 1024
|
||||
})
|
||||
|
||||
const formattedStorage = computed(() => {
|
||||
return props.storage / 1024
|
||||
return props.storage / 1024
|
||||
})
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return props.cpus / 2
|
||||
return props.cpus / 2
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
|
||||
<li class="flex items-center gap-2">
|
||||
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CpuIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
||||
<AutoLink
|
||||
v-if="burstingLink"
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
class="flex"
|
||||
:to="burstingLink"
|
||||
@click="() => emit('click-bursting-link')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</AutoLink>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="m-0 flex list-none flex-col gap-2 px-0 text-sm leading-normal text-secondary">
|
||||
<li class="flex items-center gap-2">
|
||||
<MemoryStickIcon class="h-5 w-5 shrink-0" /> {{ formattedRam }} GB RAM
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<DatabaseIcon class="h-5 w-5 shrink-0" /> {{ formattedStorage }} GB Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<CpuIcon class="h-5 w-5 shrink-0" /> {{ sharedCpus }} Shared CPUs
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<SparklesIcon class="h-5 w-5 shrink-0" /> Bursts up to {{ cpus }} CPUs
|
||||
<AutoLink
|
||||
v-if="burstingLink"
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
class="flex"
|
||||
:to="burstingLink"
|
||||
@click="() => emit('click-bursting-link')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</AutoLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg
|
||||
class="rotate outer"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
class="rotate inner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<svg
|
||||
class="rotate outer"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
class="rotate inner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div {
|
||||
height: 5rem;
|
||||
height: 5rem;
|
||||
|
||||
svg {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
position: absolute;
|
||||
&.rotate {
|
||||
animation: rotate 4s infinite linear;
|
||||
&.inner {
|
||||
animation: rotate 6s infinite linear reverse;
|
||||
}
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
position: absolute;
|
||||
&.rotate {
|
||||
animation: rotate 4s infinite linear;
|
||||
&.inner {
|
||||
animation: rotate 6s infinite linear reverse;
|
||||
}
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 3307 593"
|
||||
:class="{ animate }"
|
||||
>
|
||||
<path
|
||||
fill-rule="nonzero"
|
||||
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<g fill="var(--color-brand)">
|
||||
<path
|
||||
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
|
||||
/>
|
||||
<path
|
||||
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
|
||||
class="ring ring--large"
|
||||
/>
|
||||
<path
|
||||
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
|
||||
class="ring ring--small"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 3307 593"
|
||||
:class="{ animate }"
|
||||
>
|
||||
<path
|
||||
fill-rule="nonzero"
|
||||
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<g fill="var(--color-brand)">
|
||||
<path
|
||||
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
|
||||
/>
|
||||
<path
|
||||
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
|
||||
class="ring ring--large"
|
||||
/>
|
||||
<path
|
||||
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
|
||||
class="ring ring--small"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
animate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
animate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animate {
|
||||
.ring {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation-fill-mode: forwards;
|
||||
transition: transform 2s ease-in-out;
|
||||
&--large {
|
||||
animation: spin 1s ease-in-out infinite forwards;
|
||||
}
|
||||
&--small {
|
||||
animation: spin 2s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.ring {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation-fill-mode: forwards;
|
||||
transition: transform 2s ease-in-out;
|
||||
&--large {
|
||||
animation: spin 1s ease-in-out infinite forwards;
|
||||
}
|
||||
&--small {
|
||||
animation: spin 2s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,70 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="h-4 w-4 rounded-full border-2 border-solid border-button-border"
|
||||
:class="recent || first ? 'bg-brand' : 'bg-button-bg'"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AutoLink
|
||||
:to="
|
||||
hasLink ? `/news/changelog/${entry.product}/${entry.version ?? entry.date.unix()}` : ''
|
||||
"
|
||||
:class="{ 'hover:underline': hasLink }"
|
||||
>
|
||||
<h2 class="flex items-center gap-2 m-0 text-xl font-extrabold text-contrast">
|
||||
<template v-if="showType">
|
||||
{{ formatMessage(messages[entry.product]) }}
|
||||
<div class="w-2 h-2 rounded-full bg-secondary" />
|
||||
</template>
|
||||
<span :class="{ 'text-primary font-bold': showType }">
|
||||
{{ versionName }}
|
||||
</span>
|
||||
</h2>
|
||||
</AutoLink>
|
||||
<div
|
||||
v-if="recent"
|
||||
v-tooltip="dateTooltip"
|
||||
class="hidden sm:flex"
|
||||
:class="{ 'cursor-help': dateTooltip }"
|
||||
>
|
||||
{{ future ? formatMessage(messages.justNow) : relativeDate }}
|
||||
</div>
|
||||
<div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }">
|
||||
{{ longDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-8 mt-3 rounded-2xl bg-bg-raised px-4 py-3">
|
||||
<div class="changelog-body" v-html="renderHighlightedString(entry.body)" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="h-4 w-4 rounded-full border-2 border-solid border-button-border"
|
||||
:class="recent || first ? 'bg-brand' : 'bg-button-bg'"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<AutoLink
|
||||
:to="
|
||||
hasLink ? `/news/changelog/${entry.product}/${entry.version ?? entry.date.unix()}` : ''
|
||||
"
|
||||
:class="{ 'hover:underline': hasLink }"
|
||||
>
|
||||
<h2 class="flex items-center gap-2 m-0 text-xl font-extrabold text-contrast">
|
||||
<template v-if="showType">
|
||||
{{ formatMessage(messages[entry.product]) }}
|
||||
<div class="w-2 h-2 rounded-full bg-secondary" />
|
||||
</template>
|
||||
<span :class="{ 'text-primary font-bold': showType }">
|
||||
{{ versionName }}
|
||||
</span>
|
||||
</h2>
|
||||
</AutoLink>
|
||||
<div
|
||||
v-if="recent"
|
||||
v-tooltip="dateTooltip"
|
||||
class="hidden sm:flex"
|
||||
:class="{ 'cursor-help': dateTooltip }"
|
||||
>
|
||||
{{ future ? formatMessage(messages.justNow) : relativeDate }}
|
||||
</div>
|
||||
<div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }">
|
||||
{{ longDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-8 mt-3 rounded-2xl bg-bg-raised px-4 py-3">
|
||||
<div class="changelog-body" v-html="renderHighlightedString(entry.body)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VersionEntry } from '@modrinth/utils/changelog'
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
import type { VersionEntry } from '@modrinth/utils/changelog'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { computed, ref } from 'vue'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
|
||||
import { useRelativeTime } from '../../composables'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entry: VersionEntry
|
||||
showType?: boolean
|
||||
first?: boolean
|
||||
hasLink?: boolean
|
||||
}>(),
|
||||
{
|
||||
showType: false,
|
||||
first: false,
|
||||
hasLink: false,
|
||||
},
|
||||
defineProps<{
|
||||
entry: VersionEntry
|
||||
showType?: boolean
|
||||
first?: boolean
|
||||
hasLink?: boolean
|
||||
}>(),
|
||||
{
|
||||
showType: false,
|
||||
first: false,
|
||||
hasLink: false,
|
||||
},
|
||||
)
|
||||
|
||||
const currentDate = ref(dayjs())
|
||||
@@ -77,91 +78,91 @@ const longDate = computed(() => props.entry.date.format('MMMM D, YYYY'))
|
||||
const versionName = computed(() => props.entry.version ?? longDate.value)
|
||||
|
||||
const messages = defineMessages({
|
||||
web: {
|
||||
id: 'changelog.product.web',
|
||||
defaultMessage: 'Website',
|
||||
},
|
||||
servers: {
|
||||
id: 'changelog.product.servers',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
app: {
|
||||
id: 'changelog.product.app',
|
||||
defaultMessage: 'App',
|
||||
},
|
||||
api: {
|
||||
id: 'changelog.product.api',
|
||||
defaultMessage: 'API',
|
||||
},
|
||||
justNow: {
|
||||
id: 'changelog.justNow',
|
||||
defaultMessage: 'Just now',
|
||||
},
|
||||
web: {
|
||||
id: 'changelog.product.web',
|
||||
defaultMessage: 'Website',
|
||||
},
|
||||
servers: {
|
||||
id: 'changelog.product.servers',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
app: {
|
||||
id: 'changelog.product.app',
|
||||
defaultMessage: 'App',
|
||||
},
|
||||
api: {
|
||||
id: 'changelog.product.api',
|
||||
defaultMessage: 'API',
|
||||
},
|
||||
justNow: {
|
||||
id: 'changelog.justNow',
|
||||
defaultMessage: 'Just now',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
:deep(.changelog-body) {
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-link);
|
||||
a {
|
||||
color: var(--color-link);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
filter: brightness(1.2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
filter: brightness(1.2);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
code {
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* + p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
* + p {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
h3 + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
h3 + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
* + h3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
* + h3 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
* + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
* + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
li ul li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
li ul li {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,161 +1,162 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import Button from '../base/Button.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formatLabels: {
|
||||
type: Function,
|
||||
default: (label) => dayjs(label).format('MMM D'),
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'var(--color-brand)',
|
||||
'var(--color-blue)',
|
||||
'var(--color-purple)',
|
||||
'var(--color-red)',
|
||||
'var(--color-orange)',
|
||||
],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hideToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLegend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
hideTotal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formatLabels: {
|
||||
type: Function,
|
||||
default: (label) => dayjs(label).format('MMM D'),
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'var(--color-brand)',
|
||||
'var(--color-blue)',
|
||||
'var(--color-purple)',
|
||||
'var(--color-red)',
|
||||
'var(--color-orange)',
|
||||
],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hideToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLegend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
hideTotal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const chartOptions = ref({
|
||||
chart: {
|
||||
id: props.name,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
selection: {
|
||||
enabled: true,
|
||||
fill: {
|
||||
color: 'var(--color-brand)',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
stacked: props.stacked,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
style: {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
},
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: props.colors,
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
background: {
|
||||
enabled: true,
|
||||
borderRadius: 20,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-button-bg)',
|
||||
tickColor: 'var(--color-button-bg)',
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
strokeColor: 'var(--color-contrast)',
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1,
|
||||
hover: {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: '80%',
|
||||
endingShape: 'rounded',
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: 'end',
|
||||
borderRadiusWhenStacked: 'last',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom({ series, seriesIndex, dataPointIndex, w }) {
|
||||
console.log(seriesIndex, w)
|
||||
return (
|
||||
`<div class="bar-tooltip">` +
|
||||
`<div class="seperated-entry title">` +
|
||||
`<div class="label">${props.formatLabels(
|
||||
w.globals.lastXAxis.categories[dataPointIndex],
|
||||
)}</div>${
|
||||
!props.hideTotal
|
||||
? `<div class="value">
|
||||
chart: {
|
||||
id: props.name,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
selection: {
|
||||
enabled: true,
|
||||
fill: {
|
||||
color: 'var(--color-brand)',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
stacked: props.stacked,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
style: {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
},
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: props.colors,
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
background: {
|
||||
enabled: true,
|
||||
borderRadius: 20,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-button-bg)',
|
||||
tickColor: 'var(--color-button-bg)',
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
strokeColor: 'var(--color-contrast)',
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1,
|
||||
hover: {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: '80%',
|
||||
endingShape: 'rounded',
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: 'end',
|
||||
borderRadiusWhenStacked: 'last',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom({ series, seriesIndex, dataPointIndex, w }) {
|
||||
console.log(seriesIndex, w)
|
||||
return (
|
||||
`<div class="bar-tooltip">` +
|
||||
`<div class="seperated-entry title">` +
|
||||
`<div class="label">${props.formatLabels(
|
||||
w.globals.lastXAxis.categories[dataPointIndex],
|
||||
)}</div>${
|
||||
!props.hideTotal
|
||||
? `<div class="value">
|
||||
${props.prefix}
|
||||
${formatNumber(series.reduce((a, b) => a + b[dataPointIndex], 0).toString(), false)}
|
||||
${props.suffix}
|
||||
</div>`
|
||||
: ``
|
||||
}</div><hr class="card-divider" />${series
|
||||
.map((value, index) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
: ``
|
||||
}</div><hr class="card-divider" />${series
|
||||
.map((value, index) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
<span class="circle" style="background-color: ${w.globals.colors[index]}"> </span>
|
||||
<div class="label">
|
||||
${w.globals.seriesNames[index]}
|
||||
@@ -166,251 +167,251 @@ const chartOptions = ref({
|
||||
${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
: '',
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)}</div>`
|
||||
)
|
||||
},
|
||||
},
|
||||
: '',
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)}</div>`
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const legendValues = ref(
|
||||
[...props.data].map((project, index) => {
|
||||
return { name: project.name, visible: true, color: props.colors[index] }
|
||||
}),
|
||||
[...props.data].map((project, index) => {
|
||||
return { name: project.name, visible: true, color: props.colors[index] }
|
||||
}),
|
||||
)
|
||||
|
||||
const flipLegend = (legend, newVal) => {
|
||||
legend.visible = newVal
|
||||
chart.value.toggleSeries(legend.name)
|
||||
legend.visible = newVal
|
||||
chart.value.toggleSeries(legend.name)
|
||||
}
|
||||
|
||||
const downloadCSV = () => {
|
||||
const csvContent = `data:text/csv;charset=utf-8,${props.labels.join(',')}\n${props.data
|
||||
.map((project) => project.data.join(','))
|
||||
.reduce((a, b) => `${a}\n${b}`)}`
|
||||
const csvContent = `data:text/csv;charset=utf-8,${props.labels.join(',')}\n${props.data
|
||||
.map((project) => project.data.join(','))
|
||||
.reduce((a, b) => `${a}\n${b}`)}`
|
||||
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', encodedUri)
|
||||
link.setAttribute('download', `${props.name}.csv`)
|
||||
document.body.appendChild(link) // Required for FF
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', encodedUri)
|
||||
link.setAttribute('download', `${props.name}.csv`)
|
||||
document.body.appendChild(link) // Required for FF
|
||||
|
||||
link.click()
|
||||
link.click()
|
||||
}
|
||||
|
||||
const resetChart = () => {
|
||||
chart.value.resetSeries()
|
||||
legendValues.value.forEach((legend) => {
|
||||
legend.visible = true
|
||||
})
|
||||
chart.value.resetSeries()
|
||||
legendValues.value.forEach((legend) => {
|
||||
legend.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
downloadCSV,
|
||||
flipLegend,
|
||||
resetChart,
|
||||
downloadCSV,
|
||||
flipLegend,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-chart">
|
||||
<div class="title-bar">
|
||||
<slot />
|
||||
<div v-if="!hideToolbar" class="toolbar">
|
||||
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
|
||||
<!-- <DownloadIcon /> -->
|
||||
</Button>
|
||||
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
|
||||
<!-- <UpdatedIcon /> -->
|
||||
</Button>
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
|
||||
<div v-if="!hideLegend" class="legend">
|
||||
<Checkbox
|
||||
v-for="legend in legendValues"
|
||||
:key="legend.name"
|
||||
class="legend-checkbox"
|
||||
:style="`--color: ${legend.color};`"
|
||||
:model-value="legend.visible"
|
||||
@update:model-value="(newVal) => flipLegend(legend, newVal)"
|
||||
>
|
||||
{{ legend.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-chart">
|
||||
<div class="title-bar">
|
||||
<slot />
|
||||
<div v-if="!hideToolbar" class="toolbar">
|
||||
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
|
||||
<!-- <DownloadIcon /> -->
|
||||
</Button>
|
||||
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
|
||||
<!-- <UpdatedIcon /> -->
|
||||
</Button>
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
|
||||
<div v-if="!hideLegend" class="legend">
|
||||
<Checkbox
|
||||
v-for="legend in legendValues"
|
||||
:key="legend.name"
|
||||
class="legend-checkbox"
|
||||
:style="`--color: ${legend.color};`"
|
||||
:model-value="legend.visible"
|
||||
@update:model-value="(newVal) => flipLegend(legend, newVal)"
|
||||
>
|
||||
{{ legend.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip),
|
||||
:deep(.apexcharts-xaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
|
||||
.apexcharts-xaxistooltip-text {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
}
|
||||
.apexcharts-xaxistooltip-text {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:after) {
|
||||
border-left-color: var(--color-raised-bg) !important;
|
||||
border-left-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:before) {
|
||||
border-left-color: var(--color-button-bg) !important;
|
||||
border-left-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:after) {
|
||||
border-bottom-color: var(--color-raised-bg) !important;
|
||||
border-bottom-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:before) {
|
||||
border-bottom-color: var(--color-button-bg) !important;
|
||||
border-bottom-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu-item) {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: var(--gap-xs) var(--gap-sm) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: var(--gap-xs) var(--gap-sm) !important;
|
||||
|
||||
&:hover {
|
||||
transition: all 0.3s !important;
|
||||
color: var(--color-accent-contrast) !important;
|
||||
background: var(--color-brand) !important;
|
||||
}
|
||||
&:hover {
|
||||
transition: all 0.3s !important;
|
||||
color: var(--color-accent-contrast) !important;
|
||||
background: var(--color-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
.bar-tooltip {
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.seperated-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.seperated-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
.label {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: var(--gap-xl);
|
||||
}
|
||||
}
|
||||
.label {
|
||||
margin-right: var(--gap-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,119 +1,120 @@
|
||||
<!-- eslint-disable eslint-comments/require-description -->
|
||||
<script setup>
|
||||
import { formatNumber } from '@modrinth/utils'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
|
||||
import Card from '../base/Card.vue'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
//no grid lines, no toolbar, no legend, no data labels
|
||||
const chartOptions = ref({
|
||||
chart: {
|
||||
id: props.title,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
},
|
||||
fill: {
|
||||
colors: ['var(--color-brand)'],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: ['var(--color-brand)'],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: ['var(--color-brand)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom({ series, seriesIndex, dataPointIndex, w }) {
|
||||
console.log(seriesIndex, w)
|
||||
return `<div class="bar-tooltip">${series
|
||||
.map((value) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
chart: {
|
||||
id: props.title,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
},
|
||||
fill: {
|
||||
colors: ['var(--color-brand)'],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: ['var(--color-brand)'],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: ['var(--color-brand)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom({ series, seriesIndex, dataPointIndex, w }) {
|
||||
console.log(seriesIndex, w)
|
||||
return `<div class="bar-tooltip">${series
|
||||
.map((value) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
<div class="label">
|
||||
<span class="circle" style="background-color: ${w.globals.colors[0]}"> </span>
|
||||
${dayjs(w.globals.lastXAxis.categories[dataPointIndex]).format('MMM D')}
|
||||
@@ -127,151 +128,151 @@ const chartOptions = ref({
|
||||
${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
: '',
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)}</div>`
|
||||
},
|
||||
},
|
||||
: '',
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)}</div>`
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<VueApexCharts
|
||||
ref="chart"
|
||||
type="area"
|
||||
height="120"
|
||||
:options="chartOptions"
|
||||
:series="data"
|
||||
class="chart"
|
||||
/>
|
||||
</Card>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<VueApexCharts
|
||||
ref="chart"
|
||||
type="area"
|
||||
height="120"
|
||||
:options="chartOptions"
|
||||
:series="data"
|
||||
class="chart"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compact-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-floating);
|
||||
color: var(--color-base);
|
||||
font-size: var(--font-size-nm);
|
||||
width: 100%;
|
||||
padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-floating);
|
||||
color: var(--color-base);
|
||||
font-size: var(--font-size-nm);
|
||||
width: 100%;
|
||||
padding-bottom: 0;
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: calc(100% + 3rem);
|
||||
margin: 0 -1.5rem 0.25rem -1.5rem;
|
||||
width: calc(100% + 3rem);
|
||||
margin: 0 -1.5rem 0.25rem -1.5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-graphical) {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
.bar-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.divider {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxis) {
|
||||
line {
|
||||
stroke: none;
|
||||
}
|
||||
line {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,90 +1,91 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export interface ContentCreator {
|
||||
name: string
|
||||
type: 'user' | 'organization'
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
linkProps?: any
|
||||
name: string
|
||||
type: 'user' | 'organization'
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
linkProps?: any
|
||||
}
|
||||
|
||||
export interface ContentProject {
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
linkProps?: any
|
||||
id: string
|
||||
link?: string | RouteLocationRaw
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
linkProps?: any
|
||||
}
|
||||
|
||||
export interface ContentItem<T> {
|
||||
path: string
|
||||
disabled: boolean
|
||||
filename: string
|
||||
data: T
|
||||
path: string
|
||||
disabled: boolean
|
||||
filename: string
|
||||
data: T
|
||||
|
||||
icon?: string
|
||||
title?: string
|
||||
project?: ContentProject
|
||||
creator?: ContentCreator
|
||||
icon?: string
|
||||
title?: string
|
||||
project?: ContentProject
|
||||
creator?: ContentCreator
|
||||
|
||||
version?: string
|
||||
versionId?: string
|
||||
version?: string
|
||||
versionId?: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
item: ContentItem<T>
|
||||
last?: boolean
|
||||
}>(),
|
||||
{
|
||||
last: false,
|
||||
},
|
||||
defineProps<{
|
||||
item: ContentItem<T>
|
||||
last?: boolean
|
||||
}>(),
|
||||
{
|
||||
last: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
</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 items-center">
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<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 items-center">
|
||||
<slot name="actions" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<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'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
import type { ContentItem } from './ContentListItem.vue'
|
||||
import ContentListItem from './ContentListItem.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ContentItem<T>[]
|
||||
sortColumn: string
|
||||
sortAscending: boolean
|
||||
updateSort: (column: string) => void
|
||||
currentPage: number
|
||||
}>(),
|
||||
{},
|
||||
defineProps<{
|
||||
items: ContentItem<T>[]
|
||||
sortColumn: string
|
||||
sortAscending: boolean
|
||||
updateSort: (column: string) => void
|
||||
currentPage: number
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
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),
|
||||
),
|
||||
Object.keys(selectionStates.value).filter(
|
||||
(item) => selectionStates.value[item] && props.items.some((x) => x.filename === item),
|
||||
),
|
||||
)
|
||||
|
||||
const allSelected = ref(false)
|
||||
@@ -29,70 +30,70 @@ const allSelected = ref(false)
|
||||
const model = defineModel<string[]>()
|
||||
|
||||
function updateSelection() {
|
||||
model.value = selected.value
|
||||
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()
|
||||
if (value) {
|
||||
selectionStates.value = Object.fromEntries(props.items.map((item) => [item.filename, true]))
|
||||
} else {
|
||||
selectionStates.value = {}
|
||||
}
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
const paginatedItems = computed(() =>
|
||||
props.items.slice((props.currentPage - 1) * 20, props.currentPage * 20),
|
||||
props.items.slice((props.currentPage - 1) * 20, props.currentPage * 20),
|
||||
)
|
||||
</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">
|
||||
<ContentListItem
|
||||
v-for="(itemRef, index) in paginatedItems"
|
||||
:key="itemRef.filename"
|
||||
v-model="selectionStates[itemRef.filename]"
|
||||
:item="itemRef"
|
||||
:last="index === paginatedItems.length - 1"
|
||||
class="mb-2"
|
||||
@update:model-value="updateSelection"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
</ContentListItem>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<ContentListItem
|
||||
v-for="(itemRef, index) in paginatedItems"
|
||||
:key="itemRef.filename"
|
||||
v-model="selectionStates[itemRef.filename]"
|
||||
:item="itemRef"
|
||||
:last="index === paginatedItems.length - 1"
|
||||
class="mb-2"
|
||||
@update:model-value="updateSelection"
|
||||
>
|
||||
<template #actions="{ item }">
|
||||
<slot name="actions" :item="item" />
|
||||
</template>
|
||||
</ContentListItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
|
||||
export interface Article {
|
||||
path: string
|
||||
thumbnail: string
|
||||
title: string
|
||||
summary: string
|
||||
date: string
|
||||
path: string
|
||||
thumbnail: string
|
||||
title: string
|
||||
summary: string
|
||||
date: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
article: Article
|
||||
article: Article
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="article.path"
|
||||
class="active:scale-[0.99]! group flex flex-col transition-all ease-in-out hover:brightness-125 cursor-pointer"
|
||||
>
|
||||
<article class="flex h-full grow flex-col gap-4">
|
||||
<img
|
||||
:src="article.thumbnail"
|
||||
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover"
|
||||
/>
|
||||
<div class="flex grow flex-col gap-2">
|
||||
<h3 class="m-0 text-base leading-tight group-hover:underline">
|
||||
{{ article.title }}
|
||||
</h3>
|
||||
<p v-if="article.summary" class="m-0 text-sm leading-tight text-primary">
|
||||
{{ article.summary }}
|
||||
</p>
|
||||
<div class="mt-auto text-sm text-secondary">
|
||||
{{ dayjs(article.date).format('MMMM D, YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</AutoLink>
|
||||
<AutoLink
|
||||
:to="article.path"
|
||||
class="active:scale-[0.99]! group flex flex-col transition-all ease-in-out hover:brightness-125 cursor-pointer"
|
||||
>
|
||||
<article class="flex h-full grow flex-col gap-4">
|
||||
<img
|
||||
:src="article.thumbnail"
|
||||
class="aspect-video w-full rounded-xl border-[1px] border-solid border-button-border object-cover"
|
||||
/>
|
||||
<div class="flex grow flex-col gap-2">
|
||||
<h3 class="m-0 text-base leading-tight group-hover:underline">
|
||||
{{ article.title }}
|
||||
</h3>
|
||||
<p v-if="article.summary" class="m-0 text-sm leading-tight text-primary">
|
||||
{{ article.summary }}
|
||||
</p>
|
||||
<div class="mt-auto text-sm text-secondary">
|
||||
{{ dayjs(article.date).format('MMMM D, YYYY') }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
@@ -19,14 +19,14 @@ export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as ErrorInformationCard } from './base/ErrorInformationCard.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as FilterBar } from './base/FilterBar.vue'
|
||||
export type { FilterBarOption } from './base/FilterBar.vue'
|
||||
export { default as FilterBar } from './base/FilterBar.vue'
|
||||
export { default as HeadingLink } from './base/HeadingLink.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||
export type { Option as OverflowMenuOption } from './base/OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||
export { default as Page } from './base/Page.vue'
|
||||
export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as PopoutMenu } from './base/PopoutMenu.vue'
|
||||
@@ -59,16 +59,16 @@ export { default as CompactChart } from './chart/CompactChart.vue'
|
||||
|
||||
// Content
|
||||
export { default as ContentListPanel } from './content/ContentListPanel.vue'
|
||||
export { default as NewsArticleCard } from './content/NewsArticleCard.vue'
|
||||
export type { Article as NewsArticle } from './content/NewsArticleCard.vue'
|
||||
export { default as NewsArticleCard } from './content/NewsArticleCard.vue'
|
||||
|
||||
// Modals
|
||||
export { default as ConfirmModal } from './modal/ConfirmModal.vue'
|
||||
export { default as Modal } from './modal/Modal.vue'
|
||||
export { default as NewModal } from './modal/NewModal.vue'
|
||||
export { default as ShareModal } from './modal/ShareModal.vue'
|
||||
export { default as TabbedModal } from './modal/TabbedModal.vue'
|
||||
export type { Tab as TabbedModalTab } from './modal/TabbedModal.vue'
|
||||
export { default as TabbedModal } from './modal/TabbedModal.vue'
|
||||
|
||||
// Navigation
|
||||
export { default as Breadcrumbs } from './nav/Breadcrumbs.vue'
|
||||
|
||||
@@ -1,106 +1,107 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide">
|
||||
<template #title>
|
||||
<slot name="title">
|
||||
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="description">
|
||||
<div
|
||||
v-if="markdown"
|
||||
class="markdown-body max-w-[35rem]"
|
||||
v-html="renderString(description)"
|
||||
/>
|
||||
<p v-else class="max-w-[35rem] m-0">
|
||||
{{ description }}
|
||||
</p>
|
||||
</template>
|
||||
<slot />
|
||||
<label v-if="hasToType" for="confirmation">
|
||||
<span>
|
||||
To confirm you want to proceed, type
|
||||
<span class="italic font-bold">{{ confirmationText }}</span> below:
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
class="max-w-[20rem]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled :color="danger ? 'red' : 'brand'">
|
||||
<button :disabled="action_disabled" @click="proceed">
|
||||
<component :is="proceedIcon" />
|
||||
{{ proceedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide">
|
||||
<template #title>
|
||||
<slot name="title">
|
||||
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="description">
|
||||
<div
|
||||
v-if="markdown"
|
||||
class="markdown-body max-w-[35rem]"
|
||||
v-html="renderString(description)"
|
||||
/>
|
||||
<p v-else class="max-w-[35rem] m-0">
|
||||
{{ description }}
|
||||
</p>
|
||||
</template>
|
||||
<slot />
|
||||
<label v-if="hasToType" for="confirmation">
|
||||
<span>
|
||||
To confirm you want to proceed, type
|
||||
<span class="italic font-bold">{{ confirmationText }}</span> below:
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
class="max-w-[20rem]"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled :color="danger ? 'red' : 'brand'">
|
||||
<button :disabled="action_disabled" @click="proceed">
|
||||
<component :is="proceedIcon" />
|
||||
{{ proceedLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { ref, computed } from 'vue'
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import NewModal from './NewModal.vue'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import NewModal from './NewModal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: TrashIcon,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: TrashIcon,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
@@ -109,19 +110,19 @@ const modal = ref(null)
|
||||
const confirmation_typed = ref('')
|
||||
|
||||
const action_disabled = computed(
|
||||
() =>
|
||||
props.hasToType &&
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
|
||||
() =>
|
||||
props.hasToType &&
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
|
||||
)
|
||||
|
||||
function proceed() {
|
||||
modal.value.hide()
|
||||
confirmation_typed.value = ''
|
||||
emit('proceed')
|
||||
modal.value.hide()
|
||||
confirmation_typed.value = ''
|
||||
emit('proceed')
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<div v-if="shown">
|
||||
<div
|
||||
:class="{ shown: actuallyShown }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: actuallyShown,
|
||||
noblur: props.noblur,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="props.header" class="header">
|
||||
<h1>{{ props.header }}</h1>
|
||||
<button v-if="closable" class="btn icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div v-if="shown">
|
||||
<div
|
||||
:class="{ shown: actuallyShown }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: actuallyShown,
|
||||
noblur: props.noblur,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="props.header" class="header">
|
||||
<h1>{{ props.header }}</h1>
|
||||
<button v-if="closable" class="btn icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -36,149 +36,149 @@ import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const shown = ref(false)
|
||||
const actuallyShown = ref(false)
|
||||
|
||||
function show() {
|
||||
shown.value = true
|
||||
setTimeout(() => {
|
||||
actuallyShown.value = true
|
||||
}, 50)
|
||||
shown.value = true
|
||||
setTimeout(() => {
|
||||
actuallyShown.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
props.onHide?.()
|
||||
actuallyShown.value = false
|
||||
setTimeout(() => {
|
||||
shown.value = false
|
||||
}, 300)
|
||||
props.onHide?.()
|
||||
actuallyShown.value = false
|
||||
setTimeout(() => {
|
||||
shown.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tauri-overlay {
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 19;
|
||||
transition: all 0.3s ease-in-out;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 19;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
.modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
.modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-raised-bg);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
overflow-y: visible;
|
||||
width: 600px;
|
||||
pointer-events: auto;
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-raised-bg);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
overflow-y: visible;
|
||||
width: 600px;
|
||||
pointer-events: auto;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
transform: translateY(50vh);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
transform: translateY(50vh);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,34 +2,34 @@
|
||||
import { SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
error?: boolean
|
||||
}>(),
|
||||
{
|
||||
error: false,
|
||||
},
|
||||
defineProps<{
|
||||
error?: boolean
|
||||
}>(),
|
||||
{
|
||||
error: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
|
||||
<XCircleIcon v-if="error" class="w-6 h-6" />
|
||||
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
|
||||
<slot v-if="error" name="error" />
|
||||
<slot v-else />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold" :class="error ? 'text-red' : 'animate-pulse'">
|
||||
<XCircleIcon v-if="error" class="w-6 h-6" />
|
||||
<SpinnerIcon v-else class="w-6 h-6 animate-spin" />
|
||||
<slot v-if="error" name="error" />
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 0.95;
|
||||
}
|
||||
0%,
|
||||
100% {
|
||||
scale: 1;
|
||||
}
|
||||
50% {
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,79 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
:style="`${mouseX !== -1 ? `--_mouse-x: ${mouseX};` : ''} ${mouseY !== -1 ? `--_mouse-y: ${mouseY};` : ''}`"
|
||||
>
|
||||
<div
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
danger: danger,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@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
|
||||
data-tauri-drag-region
|
||||
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">
|
||||
<span v-if="header" class="text-lg font-extrabold text-contrast">
|
||||
{{ header }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<ButtonStyled v-if="closable" circular>
|
||||
<button v-tooltip="'Close'" aria-label="Close" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6">
|
||||
<slot> You just lost the game.</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div
|
||||
v-if="open"
|
||||
:style="`${mouseX !== -1 ? `--_mouse-x: ${mouseX};` : ''} ${mouseY !== -1 ? `--_mouse-y: ${mouseY};` : ''}`"
|
||||
>
|
||||
<div
|
||||
:class="{ shown: visible }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
danger: danger,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@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
|
||||
data-tauri-drag-region
|
||||
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">
|
||||
<span v-if="header" class="text-lg font-extrabold text-contrast">
|
||||
{{ header }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<ButtonStyled v-if="closable" circular>
|
||||
<button v-tooltip="'Close'" aria-label="Close" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6">
|
||||
<slot> You just lost the game.</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
noblur?: boolean
|
||||
closable?: boolean
|
||||
danger?: boolean
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
}>(),
|
||||
{
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: undefined,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
defineProps<{
|
||||
noblur?: boolean
|
||||
closable?: boolean
|
||||
danger?: boolean
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
}>(),
|
||||
{
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: undefined,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
)
|
||||
|
||||
const open = ref(false)
|
||||
@@ -81,169 +82,169 @@ const visible = ref(false)
|
||||
|
||||
// make modal opening not shift page when there's a vertical scrollbar
|
||||
function addBodyPadding() {
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||
if (scrollBarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollBarWidth}px`
|
||||
} else {
|
||||
document.body.style.paddingRight = ''
|
||||
}
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth
|
||||
if (scrollBarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollBarWidth}px`
|
||||
} else {
|
||||
document.body.style.paddingRight = ''
|
||||
}
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
props.onShow?.()
|
||||
open.value = true
|
||||
props.onShow?.()
|
||||
open.value = true
|
||||
|
||||
addBodyPadding()
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('mousedown', updateMousePosition)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
if (event) {
|
||||
updateMousePosition(event)
|
||||
} else {
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
}
|
||||
setTimeout(() => {
|
||||
visible.value = true
|
||||
}, 50)
|
||||
addBodyPadding()
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('mousedown', updateMousePosition)
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
if (event) {
|
||||
updateMousePosition(event)
|
||||
} else {
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
}
|
||||
setTimeout(() => {
|
||||
visible.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
props.onHide?.()
|
||||
visible.value = false
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.paddingRight = ''
|
||||
window.removeEventListener('mousedown', updateMousePosition)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
}, 300)
|
||||
props.onHide?.()
|
||||
visible.value = false
|
||||
document.body.style.overflow = ''
|
||||
document.body.style.paddingRight = ''
|
||||
window.removeEventListener('mousedown', updateMousePosition)
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
|
||||
const mouseX = ref(-1)
|
||||
const mouseY = ref(-1)
|
||||
|
||||
function updateMousePosition(event: { clientX: number; clientY: number }) {
|
||||
mouseX.value = event.clientX
|
||||
mouseY.value = event.clientY
|
||||
mouseX.value = event.clientX
|
||||
mouseY.value = event.clientY
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.closeOnEsc && event.key === 'Escape') {
|
||||
hide()
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
}
|
||||
if (props.closeOnEsc && event.key === 'Escape') {
|
||||
hide()
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tauri-overlay {
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: -5rem;
|
||||
z-index: 19;
|
||||
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%);
|
||||
//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);
|
||||
position: fixed;
|
||||
inset: -5rem;
|
||||
z-index: 19;
|
||||
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%);
|
||||
//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;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
}
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(
|
||||
calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 16),
|
||||
calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 16)
|
||||
);
|
||||
transition: all 0.2s ease-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate(
|
||||
calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 16),
|
||||
calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 16)
|
||||
);
|
||||
transition: all 0.2s ease-out;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
transform: translate(0, 0);
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
transform: translate(0, 0);
|
||||
|
||||
> .modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
> .modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
> .modal-body {
|
||||
position: fixed;
|
||||
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
max-width: min(var(--_max-width, 60rem), calc(100% - 2 * var(--gap-lg)));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: fit-content;
|
||||
pointer-events: auto;
|
||||
scale: 0.97;
|
||||
> .modal-body {
|
||||
position: fixed;
|
||||
box-shadow: 4px 4px 26px 10px rgba(0, 0, 0, 0.08);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
max-width: min(var(--_max-width, 60rem), calc(100% - 2 * var(--gap-lg)));
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: fit-content;
|
||||
pointer-events: auto;
|
||||
scale: 0.97;
|
||||
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
ShareIcon,
|
||||
MailIcon,
|
||||
GlobeIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
RedditIcon,
|
||||
ClipboardCopyIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MastodonIcon,
|
||||
RedditIcon,
|
||||
ShareIcon,
|
||||
TwitterIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { Button, Modal } from '../index'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const shareModal = ref(null)
|
||||
@@ -54,52 +55,52 @@ const content = ref(null)
|
||||
const url = ref(null)
|
||||
const canShare = ref(false)
|
||||
const share = () => {
|
||||
navigator.share(
|
||||
props.link
|
||||
? {
|
||||
title: props.shareTitle.toString(),
|
||||
text: props.shareText,
|
||||
url: url.value,
|
||||
}
|
||||
: {
|
||||
title: props.shareTitle.toString(),
|
||||
text: content.value,
|
||||
},
|
||||
)
|
||||
navigator.share(
|
||||
props.link
|
||||
? {
|
||||
title: props.shareTitle.toString(),
|
||||
text: props.shareText,
|
||||
url: url.value,
|
||||
}
|
||||
: {
|
||||
title: props.shareTitle.toString(),
|
||||
text: content.value,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const show = async (passedContent) => {
|
||||
content.value = props.shareText ? `${props.shareText}\n\n${passedContent}` : passedContent
|
||||
shareModal.value.show()
|
||||
if (props.link) {
|
||||
url.value = passedContent
|
||||
nextTick(() => {
|
||||
console.log(qrCode.value)
|
||||
fetch(qrCode.value.getElementsByTagName('canvas')[0].toDataURL('image/png'))
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
console.log(blob)
|
||||
qrImage.value = blob
|
||||
})
|
||||
})
|
||||
}
|
||||
if (navigator.canShare({ title: props.shareTitle.toString(), text: content.value })) {
|
||||
canShare.value = true
|
||||
}
|
||||
content.value = props.shareText ? `${props.shareText}\n\n${passedContent}` : passedContent
|
||||
shareModal.value.show()
|
||||
if (props.link) {
|
||||
url.value = passedContent
|
||||
nextTick(() => {
|
||||
console.log(qrCode.value)
|
||||
fetch(qrCode.value.getElementsByTagName('canvas')[0].toDataURL('image/png'))
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
console.log(blob)
|
||||
qrImage.value = blob
|
||||
})
|
||||
})
|
||||
}
|
||||
if (navigator.canShare({ title: props.shareTitle.toString(), text: content.value })) {
|
||||
canShare.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const copyImage = async () => {
|
||||
const item = new ClipboardItem({ 'image/png': qrImage.value })
|
||||
await navigator.clipboard.write([item])
|
||||
const item = new ClipboardItem({ 'image/png': qrImage.value })
|
||||
await navigator.clipboard.write([item])
|
||||
}
|
||||
|
||||
const copyText = async () => {
|
||||
await navigator.clipboard.writeText(url.value ?? content.value)
|
||||
await navigator.clipboard.writeText(url.value ?? content.value)
|
||||
}
|
||||
|
||||
const sendEmail = computed(
|
||||
() =>
|
||||
`mailto:user@test.com
|
||||
() =>
|
||||
`mailto:user@test.com
|
||||
?subject=${encodeURIComponent(props.shareTitle)}
|
||||
&body=${encodeURIComponent(content.value)}`,
|
||||
)
|
||||
@@ -107,206 +108,206 @@ const sendEmail = computed(
|
||||
const targetParameter = computed(() => (props.openInNewTab ? '_blank' : '_self'))
|
||||
|
||||
const sendTweet = computed(
|
||||
() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(content.value)}`,
|
||||
() => `https://twitter.com/intent/tweet?text=${encodeURIComponent(content.value)}`,
|
||||
)
|
||||
|
||||
const sendToot = computed(() => `https://tootpick.org/#text=${encodeURIComponent(content.value)}`)
|
||||
|
||||
const postOnReddit = computed(
|
||||
() =>
|
||||
`https://www.reddit.com/submit?title=${encodeURIComponent(props.shareTitle)}&text=${encodeURIComponent(
|
||||
content.value,
|
||||
)}`,
|
||||
() =>
|
||||
`https://www.reddit.com/submit?title=${encodeURIComponent(props.shareTitle)}&text=${encodeURIComponent(
|
||||
content.value,
|
||||
)}`,
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
|
||||
<div class="share-body">
|
||||
<div v-if="link" class="qr-wrapper">
|
||||
<div ref="qrCode">
|
||||
<QrcodeVue :value="url" class="qr-code" margin="3" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Copy QR code'"
|
||||
icon-only
|
||||
class="copy-button"
|
||||
aria-label="Copy QR code"
|
||||
@click="copyImage"
|
||||
>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="resizable-textarea-wrapper">
|
||||
<textarea v-model="content" />
|
||||
<Button
|
||||
v-tooltip="'Copy Text'"
|
||||
icon-only
|
||||
aria-label="Copy Text"
|
||||
class="copy-button transparent"
|
||||
@click="copyText"
|
||||
>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="all-buttons">
|
||||
<div v-if="link" class="iconified-input">
|
||||
<LinkIcon />
|
||||
<input type="text" :value="url" readonly />
|
||||
<Button v-tooltip="'Copy Text'" aria-label="Copy Text" class="r-btn" @click="copyText">
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button v-if="canShare" v-tooltip="'Share'" aria-label="Share" icon-only @click="share">
|
||||
<ShareIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<a
|
||||
v-tooltip="'Send as an email'"
|
||||
class="btn icon-only"
|
||||
:href="sendEmail"
|
||||
:target="targetParameter"
|
||||
aria-label="Send as an email"
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
class="btn icon-only"
|
||||
:target="targetParameter"
|
||||
:href="url"
|
||||
aria-label="Open link in browser"
|
||||
>
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
class="btn mastodon icon-only"
|
||||
:target="targetParameter"
|
||||
:href="sendToot"
|
||||
aria-label="Toot about it"
|
||||
>
|
||||
<MastodonIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
class="btn twitter icon-only"
|
||||
:target="targetParameter"
|
||||
:href="sendTweet"
|
||||
aria-label="Tweet about it"
|
||||
>
|
||||
<TwitterIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
class="btn reddit icon-only"
|
||||
:target="targetParameter"
|
||||
:href="postOnReddit"
|
||||
aria-label="Share on Reddit"
|
||||
>
|
||||
<RedditIcon aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
|
||||
<div class="share-body">
|
||||
<div v-if="link" class="qr-wrapper">
|
||||
<div ref="qrCode">
|
||||
<QrcodeVue :value="url" class="qr-code" margin="3" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Copy QR code'"
|
||||
icon-only
|
||||
class="copy-button"
|
||||
aria-label="Copy QR code"
|
||||
@click="copyImage"
|
||||
>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="resizable-textarea-wrapper">
|
||||
<textarea v-model="content" />
|
||||
<Button
|
||||
v-tooltip="'Copy Text'"
|
||||
icon-only
|
||||
aria-label="Copy Text"
|
||||
class="copy-button transparent"
|
||||
@click="copyText"
|
||||
>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="all-buttons">
|
||||
<div v-if="link" class="iconified-input">
|
||||
<LinkIcon />
|
||||
<input type="text" :value="url" readonly />
|
||||
<Button v-tooltip="'Copy Text'" aria-label="Copy Text" class="r-btn" @click="copyText">
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button v-if="canShare" v-tooltip="'Share'" aria-label="Share" icon-only @click="share">
|
||||
<ShareIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<a
|
||||
v-tooltip="'Send as an email'"
|
||||
class="btn icon-only"
|
||||
:href="sendEmail"
|
||||
:target="targetParameter"
|
||||
aria-label="Send as an email"
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
class="btn icon-only"
|
||||
:target="targetParameter"
|
||||
:href="url"
|
||||
aria-label="Open link in browser"
|
||||
>
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
class="btn mastodon icon-only"
|
||||
:target="targetParameter"
|
||||
:href="sendToot"
|
||||
aria-label="Toot about it"
|
||||
>
|
||||
<MastodonIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
class="btn twitter icon-only"
|
||||
:target="targetParameter"
|
||||
:href="sendTweet"
|
||||
aria-label="Tweet about it"
|
||||
>
|
||||
<TwitterIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
class="btn reddit icon-only"
|
||||
:target="targetParameter"
|
||||
:href="postOnReddit"
|
||||
aria-label="Share on Reddit"
|
||||
>
|
||||
<RedditIcon aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-lg);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-lg);
|
||||
}
|
||||
|
||||
.all-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
flex-basis: auto;
|
||||
}
|
||||
input {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-sm);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.btn {
|
||||
fill: var(--color-contrast);
|
||||
color: var(--color-contrast);
|
||||
.btn {
|
||||
fill: var(--color-contrast);
|
||||
color: var(--color-contrast);
|
||||
|
||||
&.reddit {
|
||||
background-color: #ff4500;
|
||||
}
|
||||
&.reddit {
|
||||
background-color: #ff4500;
|
||||
}
|
||||
|
||||
&.mastodon {
|
||||
background-color: #563acc;
|
||||
}
|
||||
&.mastodon {
|
||||
background-color: #563acc;
|
||||
}
|
||||
|
||||
&.twitter {
|
||||
background-color: #1da1f2;
|
||||
}
|
||||
}
|
||||
&.twitter {
|
||||
background-color: #1da1f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
|
||||
&:hover {
|
||||
.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
background-color: white !important;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: white !important;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: var(--gap-sm);
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: var(--gap-sm);
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-textarea-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.btn {
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { type Component, ref } from 'vue'
|
||||
import { useVIntl, type MessageDescriptor } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
export type Tab<Props> = {
|
||||
name: MessageDescriptor
|
||||
icon: Component
|
||||
content: Component<Props>
|
||||
props?: Props
|
||||
name: MessageDescriptor
|
||||
icon: Component
|
||||
content: Component<Props>
|
||||
props?: Props
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tabs: Tab<any>[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tabs: Tab<any>[]
|
||||
}>()
|
||||
|
||||
const selectedTab = ref(0)
|
||||
|
||||
function setTab(index: number) {
|
||||
selectedTab.value = index
|
||||
selectedTab.value = index
|
||||
}
|
||||
|
||||
defineExpose({ selectedTab, setTab })
|
||||
</script>
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
|
||||
>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
|
||||
@click="() => (selectedTab = index)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ formatMessage(tab.name) }}</span>
|
||||
</button>
|
||||
<div class="grid grid-cols-[auto_1fr]">
|
||||
<div
|
||||
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
|
||||
>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
|
||||
@click="() => (selectedTab = index)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ formatMessage(tab.name) }}</span>
|
||||
</button>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div class="w-[600px] h-[500px] overflow-y-auto px-4">
|
||||
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
<div class="w-[600px] h-[500px] overflow-y-auto px-4">
|
||||
<component :is="tabs[selectedTab].content" v-bind="tabs[selectedTab].props ?? {}" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
<template>
|
||||
<nav class="breadcrumbs">
|
||||
<template v-for="(link, index) in linkStack" :key="index">
|
||||
<RouterLink
|
||||
:to="link.href"
|
||||
class="breadcrumb goto-link"
|
||||
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
<ChevronRightIcon />
|
||||
</template>
|
||||
<span class="breadcrumb">{{ currentTitle }}</span>
|
||||
</nav>
|
||||
<nav class="breadcrumbs">
|
||||
<template v-for="(link, index) in linkStack" :key="index">
|
||||
<RouterLink
|
||||
:to="link.href"
|
||||
class="breadcrumb goto-link"
|
||||
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
<ChevronRightIcon />
|
||||
</template>
|
||||
<span class="breadcrumb">{{ currentTitle }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps({
|
||||
linkStack: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
linkStack: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
margin-bottom: var(--gap-lg);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
margin-bottom: var(--gap-lg);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
a.breadcrumb {
|
||||
padding-block: var(--gap-xs);
|
||||
&.trim {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
a.breadcrumb {
|
||||
padding-block: var(--gap-xs);
|
||||
&.trim {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,49 +2,49 @@
|
||||
import Button from '../base/Button.vue'
|
||||
|
||||
defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:link="link"
|
||||
:external="external"
|
||||
:action="action"
|
||||
design="nav"
|
||||
class="quiet-disabled"
|
||||
:class="{
|
||||
selected: selected,
|
||||
}"
|
||||
:disabled="selected"
|
||||
:navlabel="label"
|
||||
>
|
||||
<slot />
|
||||
{{ label }}
|
||||
</Button>
|
||||
<Button
|
||||
:link="link"
|
||||
:external="external"
|
||||
:action="action"
|
||||
design="nav"
|
||||
class="quiet-disabled"
|
||||
:class="{
|
||||
selected: selected,
|
||||
}"
|
||||
:disabled="selected"
|
||||
:navlabel="label"
|
||||
>
|
||||
<slot />
|
||||
{{ label }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,166 +1,166 @@
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<router-link
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
:key="index"
|
||||
ref="linkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="nav-link button-animation"
|
||||
>
|
||||
<span>{{ link.label }}</span>
|
||||
</router-link>
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:style="{
|
||||
left: positionToMoveX,
|
||||
top: positionToMoveY,
|
||||
width: sliderWidth,
|
||||
opacity: activeIndex === -1 ? 0 : 1,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
<nav class="navigation">
|
||||
<router-link
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
:key="index"
|
||||
ref="linkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="nav-link button-animation"
|
||||
>
|
||||
<span>{{ link.label }}</span>
|
||||
</router-link>
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:style="{
|
||||
left: positionToMoveX,
|
||||
top: positionToMoveY,
|
||||
width: sliderWidth,
|
||||
opacity: activeIndex === -1 ? 0 : 1,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
links: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
query: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sliderPositionX: 0,
|
||||
sliderPositionY: 18,
|
||||
selectedElementWidth: 0,
|
||||
activeIndex: -1,
|
||||
oldIndex: -1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredLinks() {
|
||||
return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
|
||||
},
|
||||
positionToMoveX() {
|
||||
return `${this.sliderPositionX}px`
|
||||
},
|
||||
positionToMoveY() {
|
||||
return `${this.sliderPositionY}px`
|
||||
},
|
||||
sliderWidth() {
|
||||
return `${this.selectedElementWidth}px`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.path': {
|
||||
handler() {
|
||||
this.pickLink()
|
||||
},
|
||||
},
|
||||
'$route.query': {
|
||||
handler() {
|
||||
if (this.query) this.pickLink()
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.pickLink)
|
||||
this.pickLink()
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.pickLink)
|
||||
},
|
||||
methods: {
|
||||
pickLink() {
|
||||
this.activeIndex = this.query
|
||||
? this.filteredLinks.findIndex(
|
||||
(x) => (x.href === '' ? undefined : x.href) === this.$route.path[this.query],
|
||||
)
|
||||
: this.filteredLinks.findIndex((x) => x.href === decodeURIComponent(this.$route.path))
|
||||
props: {
|
||||
links: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
query: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sliderPositionX: 0,
|
||||
sliderPositionY: 18,
|
||||
selectedElementWidth: 0,
|
||||
activeIndex: -1,
|
||||
oldIndex: -1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredLinks() {
|
||||
return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
|
||||
},
|
||||
positionToMoveX() {
|
||||
return `${this.sliderPositionX}px`
|
||||
},
|
||||
positionToMoveY() {
|
||||
return `${this.sliderPositionY}px`
|
||||
},
|
||||
sliderWidth() {
|
||||
return `${this.selectedElementWidth}px`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.path': {
|
||||
handler() {
|
||||
this.pickLink()
|
||||
},
|
||||
},
|
||||
'$route.query': {
|
||||
handler() {
|
||||
if (this.query) this.pickLink()
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.pickLink)
|
||||
this.pickLink()
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.pickLink)
|
||||
},
|
||||
methods: {
|
||||
pickLink() {
|
||||
this.activeIndex = this.query
|
||||
? this.filteredLinks.findIndex(
|
||||
(x) => (x.href === '' ? undefined : x.href) === this.$route.path[this.query],
|
||||
)
|
||||
: this.filteredLinks.findIndex((x) => x.href === decodeURIComponent(this.$route.path))
|
||||
|
||||
if (this.activeIndex !== -1) {
|
||||
this.startAnimation()
|
||||
} else {
|
||||
this.oldIndex = -1
|
||||
this.sliderPositionX = 0
|
||||
this.selectedElementWidth = 0
|
||||
}
|
||||
},
|
||||
startAnimation() {
|
||||
const el = this.$refs.linkElements[this.activeIndex].$el
|
||||
if (this.activeIndex !== -1) {
|
||||
this.startAnimation()
|
||||
} else {
|
||||
this.oldIndex = -1
|
||||
this.sliderPositionX = 0
|
||||
this.selectedElementWidth = 0
|
||||
}
|
||||
},
|
||||
startAnimation() {
|
||||
const el = this.$refs.linkElements[this.activeIndex].$el
|
||||
|
||||
this.sliderPositionX = el.offsetLeft
|
||||
this.sliderPositionY = el.offsetTop + el.offsetHeight
|
||||
this.selectedElementWidth = el.offsetWidth
|
||||
},
|
||||
},
|
||||
this.sliderPositionX = el.offsetLeft
|
||||
this.sliderPositionY = el.offsetTop + el.offsetHeight
|
||||
this.selectedElementWidth = el.offsetWidth
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navigation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
.nav-link {
|
||||
text-transform: capitalize;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-base);
|
||||
position: relative;
|
||||
.nav-link {
|
||||
text-transform: capitalize;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-base);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-base);
|
||||
&:hover {
|
||||
color: var(--color-base);
|
||||
|
||||
&::after {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
&:active::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--color-base);
|
||||
&.router-link-exact-active {
|
||||
color: var(--color-base);
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.use-animation {
|
||||
.nav-link {
|
||||
&.is-active::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.use-animation {
|
||||
.nav-link {
|
||||
&.is-active::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
height: 0.25rem;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 3rem;
|
||||
transition: all ease-in-out 0.2s;
|
||||
border-radius: var(--radius-max);
|
||||
background-color: var(--color-brand);
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
height: 0.25rem;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 3rem;
|
||||
transition: all ease-in-out 0.2s;
|
||||
border-radius: var(--radius-max);
|
||||
background-color: var(--color-brand);
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<div class="omorphia__navstack">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="omorphia__navstack">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__navstack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.btn) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
:deep(.btn) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-button-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
&.selected {
|
||||
background-color: var(--color-button-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,93 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
'location-left': notificationLocation === 'left',
|
||||
'location-right': notificationLocation === 'right',
|
||||
'has-sidebar': hasSidebar,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
:key="item.id"
|
||||
class="vue-notification-wrapper"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="w-2"
|
||||
:class="{
|
||||
'bg-red': item.type === 'error',
|
||||
'bg-orange': item.type === 'warning',
|
||||
'bg-green': item.type === 'success',
|
||||
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'success',
|
||||
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
</div>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
|
||||
<CheckIcon v-if="copied[createNotifText(item)]" />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div
|
||||
class="m-0 text-wrap text-xs font-medium text-secondary"
|
||||
v-html="item.errorCode"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
'location-left': notificationLocation === 'left',
|
||||
'location-right': notificationLocation === 'right',
|
||||
'has-sidebar': hasSidebar,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
v-for="(item, index) in notifications"
|
||||
:key="item.id"
|
||||
class="vue-notification-wrapper"
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl">
|
||||
<div
|
||||
class="w-2"
|
||||
:class="{
|
||||
'bg-red': item.type === 'error',
|
||||
'bg-orange': item.type === 'warning',
|
||||
'bg-green': item.type === 'success',
|
||||
'bg-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1 py-2 pl-1 pr-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'text-red': item.type === 'error',
|
||||
'text-orange': item.type === 'warning',
|
||||
'text-green': item.type === 'success',
|
||||
'text-blue': !item.type || !['error', 'warning', 'success'].includes(item.type),
|
||||
}"
|
||||
>
|
||||
<IssuesIcon v-if="item.type === 'warning'" class="h-6 w-6" />
|
||||
<CheckCircleIcon v-else-if="item.type === 'success'" class="h-6 w-6" />
|
||||
<XCircleIcon v-else-if="item.type === 'error'" class="h-6 w-6" />
|
||||
<InfoIcon v-else class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="m-0 text-wrap font-bold text-contrast" v-html="item.title"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div v-if="item.count && item.count > 1" class="text-xs font-bold text-contrast">
|
||||
x{{ item.count }}
|
||||
</div>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="'Copy to clipboard'" @click="copyToClipboard(item)">
|
||||
<CheckIcon v-if="copied[createNotifText(item)]" />
|
||||
<CopyIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small">
|
||||
<button v-tooltip="`Dismiss`" @click="dismissNotification(index)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="col-span-2 text-sm text-primary" v-html="item.text"></div>
|
||||
<template v-if="item.errorCode">
|
||||
<div></div>
|
||||
<div
|
||||
class="m-0 text-wrap text-xs font-medium text-secondary"
|
||||
v-html="item.errorCode"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
IssuesIcon,
|
||||
XCircleIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { injectNotificationManager, type WebNotification } from '../../providers'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
@@ -102,131 +103,131 @@ const setNotificationTimer = (n: WebNotification) => notificationManager.setNoti
|
||||
const dismissNotification = (n: number) => notificationManager.removeNotificationByIndex(n)
|
||||
|
||||
function createNotifText(notif: WebNotification): string {
|
||||
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
||||
return [notif.title, notif.text, notif.errorCode].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function checkIntercomPresence(): void {
|
||||
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
|
||||
isIntercomPresent.value = !!document.querySelector('.intercom-lightweight-app')
|
||||
}
|
||||
|
||||
function copyToClipboard(notif: WebNotification): void {
|
||||
const text = createNotifText(notif)
|
||||
const text = createNotifText(notif)
|
||||
|
||||
copied.value[text] = true
|
||||
navigator.clipboard.writeText(text)
|
||||
copied.value[text] = true
|
||||
navigator.clipboard.writeText(text)
|
||||
|
||||
setTimeout(() => {
|
||||
const { [text]: _, ...rest } = copied.value
|
||||
copied.value = rest
|
||||
}, 2000)
|
||||
setTimeout(() => {
|
||||
const { [text]: _, ...rest } = copied.value
|
||||
copied.value = rest
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIntercomPresence()
|
||||
checkIntercomPresence()
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
checkIntercomPresence()
|
||||
})
|
||||
const observer = new MutationObserver(() => {
|
||||
checkIntercomPresence()
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
hasSidebar?: boolean
|
||||
}>(),
|
||||
{
|
||||
hasSidebar: false,
|
||||
},
|
||||
defineProps<{
|
||||
hasSidebar?: boolean
|
||||
}>(),
|
||||
{
|
||||
hasSidebar: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vue-notification-group {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
z-index: 200;
|
||||
width: 450px;
|
||||
|
||||
&.location-right {
|
||||
right: 1.5rem;
|
||||
&.location-right {
|
||||
right: 1.5rem;
|
||||
|
||||
&.has-sidebar {
|
||||
right: 325px;
|
||||
}
|
||||
}
|
||||
&.has-sidebar {
|
||||
right: 325px;
|
||||
}
|
||||
}
|
||||
|
||||
&.location-left {
|
||||
left: 1.5rem;
|
||||
}
|
||||
&.location-left {
|
||||
left: 1.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 0.75rem * 2);
|
||||
bottom: 0.75rem;
|
||||
@media screen and (max-width: 500px) {
|
||||
width: calc(100% - 0.75rem * 2);
|
||||
bottom: 0.75rem;
|
||||
|
||||
&.location-right {
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
}
|
||||
&.location-right {
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
&.location-left {
|
||||
left: 0.75rem;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
&.location-left {
|
||||
left: 0.75rem;
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.intercom-present {
|
||||
bottom: 5rem;
|
||||
}
|
||||
&.intercom-present {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
|
||||
@media screen and (max-width: 750px) {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
|
||||
|
||||
&.browse-menu-open {
|
||||
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
|
||||
}
|
||||
}
|
||||
&.browse-menu-open {
|
||||
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notifs-enter-active,
|
||||
.notifs-leave-active,
|
||||
.notifs-move {
|
||||
transition: all 0.25s ease-in-out;
|
||||
transition: all 0.25s ease-in-out;
|
||||
}
|
||||
.notifs-enter-from,
|
||||
.notifs-leave-to {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.notifs-enter-from {
|
||||
transform: translateY(100%) scale(0.8);
|
||||
transform: translateY(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.notifs-leave-to {
|
||||
.location-right & {
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
.location-right & {
|
||||
transform: translateX(100%) scale(0.8);
|
||||
}
|
||||
|
||||
.location-left & {
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
.location-left & {
|
||||
transform: translateX(-100%) scale(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,82 +1,82 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'grid-area-[title] flex items-center gap-2 font-bold text-[var(--font-size-md)]',
|
||||
iconClasses[variant],
|
||||
]"
|
||||
>
|
||||
<IssuesIcon
|
||||
v-if="variant === 'warning' || variant === 'error'"
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<InfoIcon v-if="variant === 'info'" aria-hidden="true" class="w-6 h-6 flex-shrink-0" />
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div
|
||||
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'grid-area-[title] flex items-center gap-2 font-bold text-[var(--font-size-md)]',
|
||||
iconClasses[variant],
|
||||
]"
|
||||
>
|
||||
<IssuesIcon
|
||||
v-if="variant === 'warning' || variant === 'error'"
|
||||
aria-hidden="true"
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<InfoIcon v-if="variant === 'info'" aria-hidden="true" class="w-6 h-6 flex-shrink-0" />
|
||||
<slot name="title" />
|
||||
</div>
|
||||
|
||||
<div class="grid-area-[description] flex flex-col gap-[var(--gap-md)]">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
<div class="grid-area-[description] flex flex-col gap-[var(--gap-md)]">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="grid-area-[actions]">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="grid-area-[actions]">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
|
||||
<slot name="actions_right" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
|
||||
<slot name="actions_right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { InfoIcon, IssuesIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps<{
|
||||
variant: 'error' | 'warning' | 'info'
|
||||
variant: 'error' | 'warning' | 'info'
|
||||
}>()
|
||||
|
||||
const containerClasses = {
|
||||
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
||||
warning: 'bg-banners-warning-bg text-banners-warning-text border-banners-warning-border',
|
||||
info: 'bg-banners-info-bg text-banners-info-text border-banners-info-border',
|
||||
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
||||
warning: 'bg-banners-warning-bg text-banners-warning-text border-banners-warning-border',
|
||||
info: 'bg-banners-info-bg text-banners-info-text border-banners-info-border',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
error: 'text-brand-red',
|
||||
warning: 'text-brand-orange',
|
||||
info: 'text-brand-blue',
|
||||
error: 'text-brand-red',
|
||||
warning: 'text-brand-orange',
|
||||
info: 'text-brand-blue',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banner-grid {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
'title actions_right'
|
||||
'description actions_right'
|
||||
'actions actions_right';
|
||||
padding-block: var(--gap-xl);
|
||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
'title actions_right'
|
||||
'description actions_right'
|
||||
'actions actions_right';
|
||||
padding-block: var(--gap-xl);
|
||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
||||
}
|
||||
|
||||
.grid-area-\[title\] {
|
||||
grid-area: title;
|
||||
grid-area: title;
|
||||
}
|
||||
.grid-area-\[description\] {
|
||||
grid-area: description;
|
||||
grid-area: description;
|
||||
}
|
||||
.grid-area-\[actions\] {
|
||||
grid-area: actions;
|
||||
grid-area: actions;
|
||||
}
|
||||
.grid-area-\[actions_right\] {
|
||||
grid-area: actions_right;
|
||||
grid-area: actions_right;
|
||||
}
|
||||
|
||||
.banner-grid a {
|
||||
@apply underline text-current;
|
||||
@apply underline text-current;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,84 +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>
|
||||
{{ formatRelativeTime(project.date_modified ?? project.updated) }}
|
||||
</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>
|
||||
<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>
|
||||
{{ formatRelativeTime(project.date_modified ?? project.updated) }}
|
||||
</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 { DownloadIcon, HeartIcon, HistoryIcon, TagsIcon } from '@modrinth/assets'
|
||||
import { formatCategory, formatNumber } from '@modrinth/utils'
|
||||
|
||||
import { useRelativeTime } from '../../composables'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<template>
|
||||
<div :style="`--_color: ${color}`" />
|
||||
<div :style="`--_color: ${color}`" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: {
|
||||
body: string
|
||||
color: number
|
||||
}
|
||||
}>(),
|
||||
{},
|
||||
defineProps<{
|
||||
project: {
|
||||
body: string
|
||||
color: number
|
||||
}
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
function clamp(value: number) {
|
||||
return Math.max(0, Math.min(255, value))
|
||||
return Math.max(0, Math.min(255, value))
|
||||
}
|
||||
|
||||
function toHex(value: number) {
|
||||
return clamp(value).toString(16).padStart(2, '0')
|
||||
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
|
||||
const r = (decimal >> 16) & 255
|
||||
const g = (decimal >> 8) & 255
|
||||
const b = decimal & 255
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
const color = computed(() => {
|
||||
return decimalToHexColor(props.project.color)
|
||||
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;
|
||||
width: 100%;
|
||||
height: 60rem;
|
||||
background: linear-gradient(to bottom, var(--_color), transparent);
|
||||
opacity: 0.075;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +1,76 @@
|
||||
<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 cursor-help"
|
||||
:class="{ 'md:border-r': project.categories.length > 0 }"
|
||||
>
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
<span class="font-semibold">
|
||||
{{ formatNumber(project.followers) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="project.categories.length > 0" 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"
|
||||
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
|
||||
>
|
||||
{{ formatCategory(category) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
<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 cursor-help"
|
||||
:class="{ 'md:border-r': project.categories.length > 0 }"
|
||||
>
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
<span class="font-semibold">
|
||||
{{ formatNumber(project.followers) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="project.categories.length > 0" 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"
|
||||
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
|
||||
>
|
||||
{{ 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 Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
}>(),
|
||||
{
|
||||
member: false,
|
||||
},
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
}>(),
|
||||
{
|
||||
member: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
|
||||
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { renderHighlightedString } from '@modrinth/utils'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
description: string
|
||||
}>(),
|
||||
{},
|
||||
defineProps<{
|
||||
description: string
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,222 +1,222 @@
|
||||
<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" />
|
||||
{{ formatRelativeTime(version.date_published) }}
|
||||
</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>
|
||||
<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" />
|
||||
{{ formatRelativeTime(version.date_published) }}
|
||||
</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 {
|
||||
formatBytes,
|
||||
formatCategory,
|
||||
formatNumber,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag,
|
||||
type PlatformTag,
|
||||
type Version,
|
||||
} from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { type Ref, ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useRelativeTime } from '../../composables'
|
||||
import { commonMessages } from '../../utils/common-messages'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import { useRelativeTime } from '../../composables'
|
||||
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
displayUrlEnding: string
|
||||
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,
|
||||
},
|
||||
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)
|
||||
@@ -224,74 +224,74 @@ const pageSize: Ref<number> = ref(20)
|
||||
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
|
||||
|
||||
const selectedGameVersions: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedGameVersions ?? [],
|
||||
() => versionFilters.value?.selectedGameVersions ?? [],
|
||||
)
|
||||
const selectedPlatforms: Ref<string[]> = computed(
|
||||
() => versionFilters.value?.selectedPlatforms ?? [],
|
||||
() => 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),
|
||||
)
|
||||
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))
|
||||
return selected.length === 0 || selected.some((value) => values.includes(value))
|
||||
}
|
||||
|
||||
function isAnySelected(value: string, selected: string[]) {
|
||||
return selected.length === 0 || selected.includes(value)
|
||||
return selected.length === 0 || selected.includes(value)
|
||||
}
|
||||
|
||||
const currentVersions = computed(() =>
|
||||
filteredVersions.value.slice(
|
||||
(currentPage.value - 1) * pageSize.value,
|
||||
currentPage.value * pageSize.value,
|
||||
),
|
||||
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
|
||||
currentPage.value = Number(route.query.page) || 1
|
||||
}
|
||||
|
||||
function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
currentPage.value = page
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
})
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
page: currentPage.value !== 1 ? currentPage.value : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
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
|
||||
}
|
||||
if (newQueries.page) {
|
||||
currentPage.value = Number(newQueries.page)
|
||||
} else if (newQueries.page === undefined) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
...newQueries,
|
||||
},
|
||||
})
|
||||
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];
|
||||
@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>
|
||||
|
||||
@@ -1,85 +1,86 @@
|
||||
<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}`"
|
||||
:action="() => router.push(`/${project.project_type}s?g=categories:${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.project_type === '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>
|
||||
<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}`"
|
||||
:action="() => router.push(`/${project.project_type}s?g=categories:${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.project_type === '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 { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -88,37 +89,37 @@ const router = useRouter()
|
||||
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'
|
||||
|
||||
defineProps<{
|
||||
project: {
|
||||
actualProjectType: string
|
||||
project_type: string
|
||||
loaders: string[]
|
||||
client_side: EnvironmentValue
|
||||
server_side: EnvironmentValue
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
versions: any[]
|
||||
}
|
||||
tags: {
|
||||
gameVersions: GameVersionTag[]
|
||||
loaders: PlatformTag[]
|
||||
}
|
||||
project: {
|
||||
actualProjectType: string
|
||||
project_type: string
|
||||
loaders: string[]
|
||||
client_side: EnvironmentValue
|
||||
server_side: EnvironmentValue
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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',
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -1,118 +1,119 @@
|
||||
<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>
|
||||
<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 { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type TeamMember = {
|
||||
id: string
|
||||
role: string
|
||||
is_owner: boolean
|
||||
accepted: boolean
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string
|
||||
}
|
||||
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
|
||||
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 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) || []
|
||||
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)
|
||||
}
|
||||
})
|
||||
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
|
||||
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',
|
||||
},
|
||||
title: {
|
||||
id: 'project.about.creators.title',
|
||||
defaultMessage: 'Creators',
|
||||
},
|
||||
owner: {
|
||||
id: 'project.about.creators.owner',
|
||||
defaultMessage: 'Project owner',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,144 +1,145 @@
|
||||
<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>
|
||||
<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 } from 'vue'
|
||||
import { BookTextIcon, CalendarIcon, ExternalIcon, ScaleIcon, VersionIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useRelativeTime } from '../../composables'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
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
|
||||
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 ? formatRelativeTime(props.project.published) : 'unknown',
|
||||
props.project.published ? formatRelativeTime(props.project.published) : 'unknown',
|
||||
)
|
||||
const submittedDate = computed(() =>
|
||||
props.project.queued ? formatRelativeTime(props.project.queued) : 'unknown',
|
||||
props.project.queued ? formatRelativeTime(props.project.queued) : 'unknown',
|
||||
)
|
||||
const publishedDate = computed(() =>
|
||||
props.project.approved ? formatRelativeTime(props.project.approved) : 'unknown',
|
||||
props.project.approved ? formatRelativeTime(props.project.approved) : 'unknown',
|
||||
)
|
||||
const updatedDate = computed(() =>
|
||||
props.project.updated ? formatRelativeTime(props.project.updated) : 'unknown',
|
||||
props.project.updated ? formatRelativeTime(props.project.updated) : 'unknown',
|
||||
)
|
||||
|
||||
const licenseIdDisplay = computed(() => {
|
||||
const id = props.project.license.id
|
||||
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
|
||||
}
|
||||
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}',
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -1,173 +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>
|
||||
<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,
|
||||
BuyMeACoffeeIcon,
|
||||
CodeIcon,
|
||||
CurrencyIcon,
|
||||
DiscordIcon,
|
||||
ExternalIcon,
|
||||
HeartIcon,
|
||||
IssuesIcon,
|
||||
KoFiIcon,
|
||||
OpenCollectiveIcon,
|
||||
PatreonIcon,
|
||||
PayPalIcon,
|
||||
WikiIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl, defineMessages } from '@vintl/vintl'
|
||||
import { defineMessages, useVIntl } 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
|
||||
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',
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -1,106 +1,107 @@
|
||||
<template>
|
||||
<Badge :icon="metadata.icon" :formatted-name="metadata.formattedName" />
|
||||
<Badge :icon="metadata.icon" :formatted-name="metadata.formattedName" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FileTextIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
ArchiveIcon,
|
||||
CalendarIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
UnknownIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useVIntl, defineMessage, type MessageDescriptor } from '@vintl/vintl'
|
||||
import type { ProjectStatus } from '@modrinth/utils'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } 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
|
||||
status: ProjectStatus
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const metadata = computed(() => ({
|
||||
icon: statusMetadata[props.status]?.icon ?? statusMetadata.unknown.icon,
|
||||
formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status),
|
||||
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',
|
||||
}),
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -1,106 +1,107 @@
|
||||
<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>
|
||||
<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'
|
||||
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
|
||||
interface FilterOption<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
data: T
|
||||
id: string
|
||||
formatted_name: string
|
||||
data: T
|
||||
}
|
||||
|
||||
interface FilterType<T> {
|
||||
id: string
|
||||
formatted_name: string
|
||||
scrollable?: boolean
|
||||
options: FilterOption<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
|
||||
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
|
||||
name: string
|
||||
icon: string
|
||||
supported_project_types: ProjectType[]
|
||||
default: boolean
|
||||
formatted_name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
gameVersions?: GameVersion[]
|
||||
platforms: Platform[]
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
gameVersions?: GameVersion[]
|
||||
platforms: Platform[]
|
||||
}>()
|
||||
|
||||
const filters = computed(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const filters: FilterType<any>[] = [
|
||||
{
|
||||
id: 'platform',
|
||||
formatted_name: 'Platform',
|
||||
options:
|
||||
props.platforms
|
||||
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
|
||||
.map((x) => ({
|
||||
id: x.name,
|
||||
formatted_name: x.formatted_name,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'gameVersion',
|
||||
formatted_name: 'Game version',
|
||||
options:
|
||||
props.gameVersions
|
||||
?.filter((x) => x.major && x.version_type === 'release')
|
||||
.map((x) => ({
|
||||
id: x.version,
|
||||
formatted_name: x.version,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const filters: FilterType<any>[] = [
|
||||
{
|
||||
id: 'platform',
|
||||
formatted_name: 'Platform',
|
||||
options:
|
||||
props.platforms
|
||||
.filter((x) => x.default && x.supported_project_types.includes('modpack'))
|
||||
.map((x) => ({
|
||||
id: x.name,
|
||||
formatted_name: x.formatted_name,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
{
|
||||
id: 'gameVersion',
|
||||
formatted_name: 'Game version',
|
||||
options:
|
||||
props.gameVersions
|
||||
?.filter((x) => x.major && x.version_type === 'release')
|
||||
.map((x) => ({
|
||||
id: x.version,
|
||||
formatted_name: x.version,
|
||||
data: x,
|
||||
})) || [],
|
||||
},
|
||||
]
|
||||
|
||||
return filters
|
||||
return filters
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<template>
|
||||
<div class="categories">
|
||||
<slot />
|
||||
<span
|
||||
v-for="category in categories"
|
||||
:key="category.name"
|
||||
v-html="category.icon + formatCategory(category.name)"
|
||||
/>
|
||||
</div>
|
||||
<div class="categories">
|
||||
<slot />
|
||||
<span
|
||||
v-for="category in categories"
|
||||
:key="category.name"
|
||||
v-html="category.icon + formatCategory(category.name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
:deep(span) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
:deep(span) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:not(.version-badge) {
|
||||
color: var(--color-gray);
|
||||
}
|
||||
&:not(.version-badge) {
|
||||
color: var(--color-gray);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
width: 1rem;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,113 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div class="iconified-input">
|
||||
<SearchIcon />
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
class="text-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
:placeholder="placeholder"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@keydown.enter.prevent="$emit('enter')"
|
||||
/>
|
||||
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
class="option"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<div class="project-label">
|
||||
<Avatar :src="option.icon" :circle="circledIcons" />
|
||||
<div class="text">
|
||||
<div class="title">
|
||||
{{ getOptionLabel(option.title) }}
|
||||
</div>
|
||||
<div class="author">
|
||||
{{ getOptionLabel(option.subtitle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div class="iconified-input">
|
||||
<SearchIcon />
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
class="text-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
:placeholder="placeholder"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@keydown.enter.prevent="$emit('enter')"
|
||||
/>
|
||||
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
class="option"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<div class="project-label">
|
||||
<Avatar :src="option.icon" :circle="circledIcons" />
|
||||
<div class="text">
|
||||
<div class="title">
|
||||
{{ getOptionLabel(option.title) }}
|
||||
</div>
|
||||
<div class="author">
|
||||
{{ getOptionLabel(option.subtitle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { XIcon, SearchIcon } from '@modrinth/assets'
|
||||
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import Button from '../base/Button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
circledIcons: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
circledIcons: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
function getOptionLabel(option) {
|
||||
return props.displayName?.(option) ?? option
|
||||
return props.displayName?.(option) ?? option
|
||||
}
|
||||
|
||||
const emit = defineEmits(['input', 'onSelected', 'update:modelValue', 'enter'])
|
||||
@@ -119,227 +120,227 @@ const optionElements = ref(null)
|
||||
const dropdownOptions = ref(null)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('onSelected', option)
|
||||
console.log('onSelected', option)
|
||||
dropdownVisible.value = false
|
||||
emit('onSelected', option)
|
||||
console.log('onSelected', option)
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex(
|
||||
(option) => option === props.modelValue.value,
|
||||
)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex(
|
||||
(option) => option === props.modelValue.value,
|
||||
)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const onBlur = (event) => {
|
||||
console.log(event)
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
console.log(event)
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value + props.options.length - 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const focusNextOptionOrOpen = () => {
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
if (!props.disabled) {
|
||||
if (!dropdownVisible.value) {
|
||||
toggleDropdown()
|
||||
}
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
|
||||
optionElements.value[focusedOptionIndex.value].focus()
|
||||
}
|
||||
}
|
||||
|
||||
const isChildOfDropdown = (element) => {
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdownOptions.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdownOptions.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animated-dropdown {
|
||||
width: 20rem;
|
||||
height: 2.5rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 20rem;
|
||||
height: 2.5rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
.selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent;
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
&.render-up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.render-down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.1s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(0.85);
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-active,
|
||||
.options-leave-active {
|
||||
transition: transform 0.2s ease;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.options-enter-from,
|
||||
.options-leave-to {
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
// this is not 100% due to a safari bug
|
||||
&.up {
|
||||
transform: translateY(99.999%);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
&.down {
|
||||
transform: translateY(-99.999%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-enter-to,
|
||||
.options-leave-from {
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
&.up {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
z-index: 9;
|
||||
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
&.up {
|
||||
top: 0;
|
||||
transform: translateY(-99.999%);
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
&.down {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
.project-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent !important;
|
||||
width: 100%;
|
||||
box-shadow:
|
||||
var(--shadow-inset-sm),
|
||||
0 0 0 0 transparent !important;
|
||||
width: 100%;
|
||||
|
||||
transition: 0.05s;
|
||||
transition: 0.05s;
|
||||
|
||||
&:focus {
|
||||
&.down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
|
||||
}
|
||||
&:focus {
|
||||
&.down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
|
||||
}
|
||||
|
||||
&.up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
|
||||
}
|
||||
}
|
||||
&.up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:focus) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
&:not(:focus) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,75 @@
|
||||
<template>
|
||||
<Checkbox
|
||||
class="filter"
|
||||
:model-value="isActive"
|
||||
:description="displayName"
|
||||
@update:model-value="toggle"
|
||||
>
|
||||
<div class="filter-text">
|
||||
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
|
||||
<div v-else class="icon">
|
||||
<slot />
|
||||
</div>
|
||||
<span aria-hidden="true"> {{ props.displayName }}</span>
|
||||
</div>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
class="filter"
|
||||
:model-value="isActive"
|
||||
:description="displayName"
|
||||
@update:model-value="toggle"
|
||||
>
|
||||
<div class="filter-text">
|
||||
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
|
||||
<div v-else class="icon">
|
||||
<slot />
|
||||
</div>
|
||||
<span aria-hidden="true"> {{ props.displayName }}</span>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
facetName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
displayName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeFilters: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
facetName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
displayName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeFilters: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const isActive = computed(() => props.activeFilters.includes(props.facetName))
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const toggle = () => {
|
||||
emit('toggle', props.facetName)
|
||||
emit('toggle', props.facetName)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
:deep(.filter-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:deep(.filter-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
.icon {
|
||||
height: 1rem;
|
||||
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
user-select: none;
|
||||
}
|
||||
span {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,97 +1,98 @@
|
||||
<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>
|
||||
<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 { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = defineModel<FilterValue[]>('selectedFilters', { required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterType[]
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor
|
||||
filters: FilterType[]
|
||||
providedFilters: FilterValue[]
|
||||
overriddenProvidedFilterTypes: string[]
|
||||
providedMessage?: MessageDescriptor
|
||||
}>()
|
||||
|
||||
const defaultProvidedMessage = defineMessage({
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
id: 'search.filter.locked.default',
|
||||
defaultMessage: 'Filter locked',
|
||||
})
|
||||
|
||||
type Item = {
|
||||
type: string
|
||||
option: string
|
||||
negative?: boolean
|
||||
formatted_name?: string
|
||||
provided: boolean
|
||||
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)
|
||||
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,
|
||||
})),
|
||||
)
|
||||
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,
|
||||
)
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(x) => x.type !== filter.type || x.option !== filter.option,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = []
|
||||
selectedFilters.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,64 +1,65 @@
|
||||
<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>
|
||||
<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,
|
||||
},
|
||||
defineProps<{
|
||||
option: FilterOption
|
||||
included: boolean
|
||||
excluded: boolean
|
||||
supportsNegativeFilter?: boolean
|
||||
}>(),
|
||||
{
|
||||
supportsNegativeFilter: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [option: FilterOption]
|
||||
toggleExclude: [option: FilterOption]
|
||||
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;
|
||||
}
|
||||
button,
|
||||
.filter-action-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,190 +1,191 @@
|
||||
<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>
|
||||
<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>
|
||||
<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,
|
||||
BanIcon,
|
||||
DropdownIcon,
|
||||
LockOpenIcon,
|
||||
SearchIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} 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'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
|
||||
import Accordion from '../base/Accordion.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import { Button, Checkbox, ScrollablePanel } from '../index'
|
||||
import SearchFilterOption from './SearchFilterOption.vue'
|
||||
|
||||
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: [],
|
||||
required: false,
|
||||
default: [],
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
filterType: FilterType
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
filterType: FilterType
|
||||
buttonClass?: string
|
||||
contentClass?: string
|
||||
innerPanelClass?: string
|
||||
openByDefault?: boolean
|
||||
providedFilters: FilterValue[]
|
||||
}>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const query = ref('')
|
||||
@@ -193,142 +194,142 @@ 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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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)
|
||||
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
|
||||
}),
|
||||
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),
|
||||
props.providedFilters.some((filter) => filter.type === props.filterType.id),
|
||||
)
|
||||
const locked = computed(
|
||||
() =>
|
||||
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
|
||||
() =>
|
||||
hasProvidedFilter.value && !overriddenProvidedFilterTypes.value.includes(props.filterType.id),
|
||||
)
|
||||
|
||||
const scrollable = computed(
|
||||
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
|
||||
() => visibleOptions.value.length >= 10 && props.filterType.display === 'scrollable',
|
||||
)
|
||||
|
||||
function groupEnabled(group: string) {
|
||||
return toggledGroups.value.includes(group)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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())
|
||||
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 (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
|
||||
}
|
||||
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,
|
||||
)
|
||||
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')
|
||||
setFilter(filter, isIncluded(filter) || isExcluded(filter) ? 'ignore' : 'include')
|
||||
}
|
||||
|
||||
function toggleNegativeFilter(filter: FilterOption) {
|
||||
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
|
||||
setFilter(filter, isExcluded(filter) ? 'ignore' : 'exclude')
|
||||
}
|
||||
|
||||
function setFilter(filter: FilterOption, state: FilterState) {
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
const newFilters = selectedFilters.value.filter((selected) => selected.option !== filter.id)
|
||||
|
||||
const baseValues = {
|
||||
type: props.filterType.id,
|
||||
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,
|
||||
})
|
||||
}
|
||||
if (state === 'include') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: false,
|
||||
})
|
||||
} else if (state === 'exclude') {
|
||||
newFilters.push({
|
||||
...baseValues,
|
||||
negative: true,
|
||||
})
|
||||
}
|
||||
|
||||
selectedFilters.value = newFilters
|
||||
selectedFilters.value = newFilters
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
selectedFilters.value = selectedFilters.value.filter(
|
||||
(filter) => filter.type !== props.filterType.id,
|
||||
)
|
||||
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.',
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, ModrinthIcon, XIcon } from '@modrinth/assets'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import { ModrinthIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
link: string
|
||||
closable?: boolean
|
||||
}>(),
|
||||
{
|
||||
closable: true,
|
||||
},
|
||||
defineProps<{
|
||||
link: string
|
||||
closable?: boolean
|
||||
}>(),
|
||||
{
|
||||
closable: true,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="brand-gradient-bg card-shadow bg-bg relative p-4 border-[1px] border-solid border-brand rounded-2xl grid grid-cols-[1fr_auto] overflow-hidden"
|
||||
>
|
||||
<ModrinthIcon
|
||||
class="absolute -top-12 -right-12 size-48 text-brand-highlight opacity-25"
|
||||
fill="none"
|
||||
stroke="var(--color-brand)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg leading-tight font-extrabold text-contrast"
|
||||
>Want to play with <br />
|
||||
<span class="text-brand">your friends?</span></span
|
||||
>
|
||||
<span class="text-sm font-medium">Create a server with Modrinth in just a few clicks.</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end justify-end z-10">
|
||||
<ButtonStyled color="brand">
|
||||
<AutoLink :to="link"> View plans <RightArrowIcon /> </AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 z-10">
|
||||
<ButtonStyled v-if="closable" size="small" circular>
|
||||
<button v-tooltip="`Don't show again`" @click="emit('close')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="brand-gradient-bg card-shadow bg-bg relative p-4 border-[1px] border-solid border-brand rounded-2xl grid grid-cols-[1fr_auto] overflow-hidden"
|
||||
>
|
||||
<ModrinthIcon
|
||||
class="absolute -top-12 -right-12 size-48 text-brand-highlight opacity-25"
|
||||
fill="none"
|
||||
stroke="var(--color-brand)"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg leading-tight font-extrabold text-contrast"
|
||||
>Want to play with <br />
|
||||
<span class="text-brand">your friends?</span></span
|
||||
>
|
||||
<span class="text-sm font-medium">Create a server with Modrinth in just a few clicks.</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end justify-end z-10">
|
||||
<ButtonStyled color="brand">
|
||||
<AutoLink :to="link"> View plans <RightArrowIcon /> </AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="absolute top-2 right-2 z-10">
|
||||
<ButtonStyled v-if="closable" size="small" circular>
|
||||
<button v-tooltip="`Don't show again`" @click="emit('close')">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.brand-gradient-bg {
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
var(--color-brand-highlight) -80%,
|
||||
var(--color-bg)
|
||||
);
|
||||
--color-button-bg: var(--brand-gradient-button);
|
||||
background-image: linear-gradient(
|
||||
to top right,
|
||||
var(--color-brand-highlight) -80%,
|
||||
var(--color-bg)
|
||||
);
|
||||
--color-button-bg: var(--brand-gradient-button);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { IssuesIcon } from '@modrinth/assets'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
backupLink: string
|
||||
backupLink: string
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-3 rounded-2xl border-2 border-solid border-orange bg-bg-orange px-4 py-3 font-medium text-contrast"
|
||||
>
|
||||
<IssuesIcon class="mt-1 h-5 w-5 shrink-0 text-orange" />
|
||||
<span class="leading-normal">
|
||||
You may want to
|
||||
<AutoLink
|
||||
:to="backupLink"
|
||||
class="font-semibold text-orange hover:underline active:brightness-125"
|
||||
>create a backup</AutoLink
|
||||
>
|
||||
before proceeding, as this process is irreversible and may permanently alter your world or the
|
||||
files on your server.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-3 rounded-2xl border-2 border-solid border-orange bg-bg-orange px-4 py-3 font-medium text-contrast"
|
||||
>
|
||||
<IssuesIcon class="mt-1 h-5 w-5 shrink-0 text-orange" />
|
||||
<span class="leading-normal">
|
||||
You may want to
|
||||
<AutoLink
|
||||
:to="backupLink"
|
||||
class="font-semibold text-orange hover:underline active:brightness-125"
|
||||
>create a backup</AutoLink
|
||||
>
|
||||
before proceeding, as this process is irreversible and may permanently alter your world or the
|
||||
files on your server.
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,133 +5,133 @@ import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
updateColorTheme: (theme: T) => void
|
||||
currentTheme: T
|
||||
themeOptions: readonly T[]
|
||||
systemThemeColor: T
|
||||
updateColorTheme: (theme: T) => void
|
||||
currentTheme: T
|
||||
themeOptions: readonly T[]
|
||||
systemThemeColor: T
|
||||
}>()
|
||||
|
||||
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',
|
||||
},
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
function asString(theme: T): string {
|
||||
return theme
|
||||
return 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">
|
||||
<RadioButtonCheckedIcon v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[asString(option)] ? formatMessage(colorTheme[asString(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>
|
||||
<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">
|
||||
<RadioButtonCheckedIcon v-if="currentTheme === option" class="radio" />
|
||||
<RadioButtonIcon v-else class="radio" />
|
||||
{{ colorTheme[asString(option)] ? formatMessage(colorTheme[asString(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(10rem, 1fr));
|
||||
gap: var(--gap-lg);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 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;
|
||||
.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-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,
|
||||
.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-1 {
|
||||
grid-area: text1;
|
||||
width: 100%;
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
.example-text-2 {
|
||||
grid-area: text2;
|
||||
width: 60%;
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'select'): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string | undefined
|
||||
id: string
|
||||
texture: string
|
||||
isEquipped?: boolean
|
||||
selected?: boolean
|
||||
faded?: boolean
|
||||
}>(),
|
||||
{
|
||||
isEquipped: false,
|
||||
selected: undefined,
|
||||
faded: false,
|
||||
},
|
||||
defineProps<{
|
||||
name: string | undefined
|
||||
id: string
|
||||
texture: string
|
||||
isEquipped?: boolean
|
||||
selected?: boolean
|
||||
faded?: boolean
|
||||
}>(),
|
||||
{
|
||||
isEquipped: false,
|
||||
selected: undefined,
|
||||
faded: false,
|
||||
},
|
||||
)
|
||||
|
||||
console.log(props)
|
||||
@@ -27,82 +27,82 @@ const highlighted = computed(() => props.selected ?? props.isEquipped)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-tooltip="name"
|
||||
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
|
||||
:aria-label="name"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
highlighted
|
||||
? `bg-brand highlighted-outer-glow`
|
||||
: `bg-button-bg brightness-95 group-hover:brightness-100`
|
||||
"
|
||||
class="relative block p-[3px] rounded-lg border-0 group-active:scale-95 transition-all"
|
||||
>
|
||||
<span
|
||||
class="block magical-cape-transform rounded-[5px]"
|
||||
:class="{
|
||||
'highlighted-inner-shadow': highlighted,
|
||||
'brightness-[0.3] contrast-[0.8]': faded,
|
||||
}"
|
||||
>
|
||||
<img :src="texture" alt="" />
|
||||
</span>
|
||||
<span
|
||||
v-if="$slots.default || $slots.icon"
|
||||
class="p-4 absolute inset-0 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
<span class="mb-1">
|
||||
<slot name="icon"></slot>
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="name"
|
||||
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
|
||||
:aria-label="name"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
highlighted
|
||||
? `bg-brand highlighted-outer-glow`
|
||||
: `bg-button-bg brightness-95 group-hover:brightness-100`
|
||||
"
|
||||
class="relative block p-[3px] rounded-lg border-0 group-active:scale-95 transition-all"
|
||||
>
|
||||
<span
|
||||
class="block magical-cape-transform rounded-[5px]"
|
||||
:class="{
|
||||
'highlighted-inner-shadow': highlighted,
|
||||
'brightness-[0.3] contrast-[0.8]': faded,
|
||||
}"
|
||||
>
|
||||
<img :src="texture" alt="" />
|
||||
</span>
|
||||
<span
|
||||
v-if="$slots.default || $slots.icon"
|
||||
class="p-4 absolute inset-0 flex items-center justify-center text-primary font-medium"
|
||||
>
|
||||
<span class="mb-1">
|
||||
<slot name="icon"></slot>
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.magical-cape-transform {
|
||||
aspect-ratio: 10 / 16;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: content-box;
|
||||
width: 60px;
|
||||
min-height: 96px;
|
||||
aspect-ratio: 10 / 16;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: content-box;
|
||||
width: 60px;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.magical-cape-transform img {
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
image-rendering: pixelated;
|
||||
|
||||
// scales image up so that the target area of the texture (10x16) is 100% of the container
|
||||
width: calc(64 / 10 * 100%);
|
||||
height: calc(32 / 16 * 100%);
|
||||
// scales image up so that the target area of the texture (10x16) is 100% of the container
|
||||
width: calc(64 / 10 * 100%);
|
||||
height: calc(32 / 16 * 100%);
|
||||
|
||||
// offsets the image so that the target area is in the container
|
||||
left: calc(1 / 10 * -100%);
|
||||
top: calc(1 / 16 * -100%);
|
||||
// offsets the image so that the target area is in the container
|
||||
left: calc(1 / 10 * -100%);
|
||||
top: calc(1 / 16 * -100%);
|
||||
|
||||
// scale the image up a little bit to avoid edges from the surrounding texture due to rounding
|
||||
scale: 1.01;
|
||||
transform-origin: calc(10 / 2 / 64 * 100%) calc(16 / 2 / 32 * 100%);
|
||||
// scale the image up a little bit to avoid edges from the surrounding texture due to rounding
|
||||
scale: 1.01;
|
||||
transform-origin: calc(10 / 2 / 64 * 100%) calc(16 / 2 / 32 * 100%);
|
||||
}
|
||||
|
||||
.highlighted-inner-shadow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 4px 4px rgba(0, 0, 0, 0.4);
|
||||
z-index: 2;
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-shadow: inset 0 0 4px 4px rgba(0, 0, 0, 0.4);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
|
||||
.highlighted-glow::before {
|
||||
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
|
||||
}
|
||||
.highlighted-glow::before {
|
||||
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
tooltip?: string
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
tooltip: undefined,
|
||||
highlighted: false,
|
||||
},
|
||||
defineProps<{
|
||||
tooltip?: string
|
||||
highlighted?: boolean
|
||||
}>(),
|
||||
{
|
||||
tooltip: undefined,
|
||||
highlighted: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-tooltip="tooltip"
|
||||
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
|
||||
:aria-label="tooltip"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'block rounded-lg group-active:scale-95 transition-all border-2 relative',
|
||||
highlighted
|
||||
? 'border-brand highlighted-glow'
|
||||
: 'border-transparent brightness-95 group-hover:brightness-100',
|
||||
]"
|
||||
>
|
||||
<span class="block p-[3px] rounded-lg bg-button-bg">
|
||||
<span
|
||||
class="flex flex-col p-4 items-center justify-center aspect-[10/16] w-[60px] min-h-[96px] rounded-[5px] bg-black/10 relative overflow-hidden text-primary z-10"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<span class="text-xs">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="tooltip"
|
||||
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
|
||||
:aria-label="tooltip"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'block rounded-lg group-active:scale-95 transition-all border-2 relative',
|
||||
highlighted
|
||||
? 'border-brand highlighted-glow'
|
||||
: 'border-transparent brightness-95 group-hover:brightness-100',
|
||||
]"
|
||||
>
|
||||
<span class="block p-[3px] rounded-lg bg-button-bg">
|
||||
<span
|
||||
class="flex flex-col p-4 items-center justify-center aspect-[10/16] w-[60px] min-h-[96px] rounded-[5px] bg-black/10 relative overflow-hidden text-primary z-10"
|
||||
>
|
||||
<div class="mb-1">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<span class="text-xs">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.highlighted-glow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
|
||||
.highlighted-glow::before {
|
||||
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
|
||||
}
|
||||
.highlighted-glow::before {
|
||||
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,141 +2,141 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select'): void
|
||||
(e: 'edit', event: MouseEvent): void
|
||||
(e: 'select'): void
|
||||
(e: 'edit', event: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
forwardImageSrc?: string
|
||||
backwardImageSrc?: string
|
||||
selected: boolean
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
forwardImageSrc: undefined,
|
||||
backwardImageSrc: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
defineProps<{
|
||||
forwardImageSrc?: string
|
||||
backwardImageSrc?: string
|
||||
selected: boolean
|
||||
tooltip?: string
|
||||
}>(),
|
||||
{
|
||||
forwardImageSrc: undefined,
|
||||
backwardImageSrc: undefined,
|
||||
tooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const imagesLoaded = ref({
|
||||
forward: Boolean(props.forwardImageSrc),
|
||||
backward: Boolean(props.backwardImageSrc),
|
||||
forward: Boolean(props.forwardImageSrc),
|
||||
backward: Boolean(props.backwardImageSrc),
|
||||
})
|
||||
|
||||
function onImageLoad(type: 'forward' | 'backward') {
|
||||
imagesLoaded.value[type] = true
|
||||
imagesLoaded.value[type] = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-tooltip="tooltip ?? undefined"
|
||||
class="group flex relative overflow-hidden rounded-xl border-solid border-2 transition-colors duration-200"
|
||||
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
|
||||
>
|
||||
<button
|
||||
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125"
|
||||
:class="selected ? 'selected' : ''"
|
||||
@click="emit('select')"
|
||||
></button>
|
||||
<div
|
||||
v-tooltip="tooltip ?? undefined"
|
||||
class="group flex relative overflow-hidden rounded-xl border-solid border-2 transition-colors duration-200"
|
||||
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
|
||||
>
|
||||
<button
|
||||
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125"
|
||||
:class="selected ? 'selected' : ''"
|
||||
@click="emit('select')"
|
||||
></button>
|
||||
|
||||
<div
|
||||
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
|
||||
class="skeleton-loader w-full h-full"
|
||||
>
|
||||
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
|
||||
class="skeleton-loader w-full h-full"
|
||||
>
|
||||
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-show="imagesLoaded.forward && imagesLoaded.backward"
|
||||
:class="[
|
||||
'skin-button__image-parent pointer-events-none w-full h-full grid [transform-style:preserve-3d] transition-transform duration-500 group-hover:[transform:rotateY(180deg)] place-items-stretch with-shadow',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
:src="forwardImageSrc"
|
||||
class="skin-button__image-facing object-contain w-full h-full [backface-visibility:hidden] col-start-1 row-start-1"
|
||||
height="504"
|
||||
@load="onImageLoad('forward')"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
:src="backwardImageSrc"
|
||||
class="skin-button__image-away object-contain w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] col-start-1 row-start-1"
|
||||
height="504"
|
||||
@load="onImageLoad('backward')"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-show="imagesLoaded.forward && imagesLoaded.backward"
|
||||
:class="[
|
||||
'skin-button__image-parent pointer-events-none w-full h-full grid [transform-style:preserve-3d] transition-transform duration-500 group-hover:[transform:rotateY(180deg)] place-items-stretch with-shadow',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
:src="forwardImageSrc"
|
||||
class="skin-button__image-facing object-contain w-full h-full [backface-visibility:hidden] col-start-1 row-start-1"
|
||||
height="504"
|
||||
@load="onImageLoad('forward')"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
:src="backwardImageSrc"
|
||||
class="skin-button__image-away object-contain w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] col-start-1 row-start-1"
|
||||
height="504"
|
||||
@load="onImageLoad('backward')"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="$slots['overlay-buttons']"
|
||||
class="pointer-events-none absolute inset-0 flex items-end justify-start p-1 gap-1 translate-y-4 scale-75 opacity-0 transition-all group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 group-hover:translate-x-0"
|
||||
>
|
||||
<slot name="overlay-buttons" />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="$slots['overlay-buttons']"
|
||||
class="pointer-events-none absolute inset-0 flex items-end justify-start p-1 gap-1 translate-y-4 scale-75 opacity-0 transition-all group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 group-hover:translate-x-0"
|
||||
>
|
||||
<slot name="overlay-buttons" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.skeleton-loader {
|
||||
aspect-ratio: 5 / 7;
|
||||
aspect-ratio: 5 / 7;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg) 25%,
|
||||
var(--color-raised-bg) 50%,
|
||||
var(--color-bg) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wave 1500ms infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg) 25%,
|
||||
var(--color-raised-bg) 50%,
|
||||
var(--color-bg) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wave 1500ms infinite linear;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skin-btn-bg {
|
||||
background: var(--color-gradient-button-bg);
|
||||
background: var(--color-gradient-button-bg);
|
||||
}
|
||||
|
||||
.skin-btn-bg.selected {
|
||||
background: linear-gradient(
|
||||
157.61deg,
|
||||
var(--color-brand) -76.68%,
|
||||
rgba(27, 217, 106, 0.534) -38.61%,
|
||||
rgba(12, 89, 44, 0.6) 100.4%
|
||||
),
|
||||
var(--color-bg);
|
||||
background: linear-gradient(
|
||||
157.61deg,
|
||||
var(--color-brand) -76.68%,
|
||||
rgba(27, 217, 106, 0.534) -38.61%,
|
||||
rgba(12, 89, 44, 0.6) 100.4%
|
||||
),
|
||||
var(--color-bg);
|
||||
}
|
||||
|
||||
.skin-btn-bg.selected:hover,
|
||||
.group:hover .skin-btn-bg.selected {
|
||||
filter: brightness(1.15);
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.with-shadow img {
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
.skin-button__image-parent img {
|
||||
transition: filter 200ms ease-in-out;
|
||||
transition: filter 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.group:hover .skin-button__image-parent img {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.with-shadow img {
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user