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:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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 })

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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