Project, Search, User redesign (#1281)

* New project page

* fix silly icon tailwind classes

* Start new versions page, add new ButtonStyled component

* Pagination and finish mocking up versions page functionality

* green download button

* hover animation

* New Modal, Avatar refactor, subpages in NavTabs

* lint

* Download modal

* New user page + fix lint

* fix ui lint

* Download animation fix

* Versions filter + finish project page

* Improve consistency of buttons on home page

* Fix ButtonStyled breaking

* Fix margin on version summary

* finish search, new modals, user + project page mobile

* fix gallery image pages

* New project header

* Fix gallery tab showing improperly

* Use auto direction + position for all popouts

* Preliminary user page

* test to see if this fixes login stuff

* remove extra slash

* Add version actions, move download button on versions page

* Listed -> public

* Shorten download modal selector height

* Fix user menu open direction

* Change breakpoint for header collapse

* Only underline title

* Tighten padding on stats a little

* New nav

* Make mobile breakpoint more consistent

* fix header breakpoint regression

* Add sign in button

* Fix edit icon color

* Fix margin at top of screen

* Fix user bios and ad width

* Fix user nav showing when there's only one type of project

* Fix plural projects on user page & extract i18n

* Remove ads on mobile for now

* Fix overflow menu showing hidden items

* NavTabs on mobile

* Fix navbar z index

* Search filter overhaul + negative filters

* fix no-max-height

* port version filters, fix following/collections, lint

* hide promos

* ui lint

* Disable modal background animation to reduce reported motion sickness

* Hide install with modrinth app button on mobile

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
Geometrically
2024-08-20 23:03:16 -07:00
committed by GitHub
parent a19ce0458a
commit 2d416d491c
101 changed files with 5361 additions and 4488 deletions

View File

@@ -2,9 +2,14 @@
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
:style="`--_size: ${cssSize}`"
class="`experimental-styles-within avatar"
:class="{
circle: circle,
'no-shadow': noShadow,
raised: raised,
pixelated: raised,
}"
:src="src"
:alt="alt"
:loading="loading"
@@ -12,9 +17,13 @@
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
class="`experimental-styles-within avatar"
:style="`--_size: ${cssSize}`"
:class="{
circle: circle,
'no-shadow': noShadow,
raised: raised,
}"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -35,12 +44,10 @@
</template>
<script setup>
import { ref } from 'vue'
const pixelated = ref(false)
const img = ref(null)
defineProps({
const props = defineProps({
src: {
type: String,
default: null,
@@ -51,10 +58,7 @@ defineProps({
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xxs', 'xs', 'sm', 'md', 'lg', 'none'].includes(value)
},
default: '2rem',
},
circle: {
type: Boolean,
@@ -66,7 +70,7 @@ defineProps({
},
loading: {
type: String,
default: 'lazy',
default: 'eager',
},
raised: {
type: Boolean,
@@ -74,58 +78,43 @@ defineProps({
},
})
const LEGACY_PRESETS = {
xxs: '1.25rem',
xs: '2.5rem',
sm: '3rem',
md: '6rem',
lg: '9rem',
}
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
function updatePixelated() {
pixelated.value = Boolean(img.value && img.value.naturalWidth && img.value.naturalWidth <= 96)
if (img.value && img.value.naturalWidth && img.value.naturalWidth <= 96) {
pixelated.value = true
} else {
pixelated.value = false
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--radius-md);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
min-height: var(--size) !important;
min-width: var(--size) !important;
@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: cover;
max-width: var(--size) !important;
max-height: var(--size) !important;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-md {
--size: 6rem;
border-radius: var(--radius-lg);
}
&.size-lg {
--size: 9rem;
border-radius: var(--radius-lg);
}
&.size-none {
--size: unset;
}
object-fit: contain;
border-radius: calc(16 / 96 * var(--_size));
&.circle {
border-radius: 50%;
}
&:not(.no-shadow) {
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
}
&.no-shadow {
box-shadow: none;
}

View File

@@ -80,6 +80,13 @@ const classes = computed(() => {
:class="classes"
:to="link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
@@ -91,6 +98,13 @@ const classes = computed(() => {
:class="classes"
:href="link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />

View File

@@ -156,42 +156,97 @@ const colorVariables = computed(() => {
</div>
</template>
<style scoped>
<style scoped lang="scss">
.btn-wrapper {
display: contents;
}
/* Searches up to 4 children deep for valid button */
.btn-wrapper :slotted(:is(button, a):first-child),
.btn-wrapper :slotted(*) > :is(button, a):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a):first-child,
.btn-wrapper :slotted(*) > *:first-child > *:first-child > :is(button, a):first-child {
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent active:scale-95 hover:brightness-125 focus-visible:brightness-125 bg-[--_bg] text-[--_text] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out;
}
.btn-wrapper.outline :slotted(:is(button, a):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > *:first-child > :is(button, a):first-child {
@apply border-current;
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :slotted(:is(button, a):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a):first-child > svg:first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a):first-child > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a):first-child
> :is(button, a, .button-like):first-child {
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
&[disabled],
&[disabled='true'],
&.disabled,
&.looks-disabled {
@apply opacity-50;
}
&[disabled],
&[disabled='true'],
&.disabled {
@apply cursor-not-allowed;
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
}
}
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper.outline
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply border-current;
}
/*noinspection CssUnresolvedCustomProperty*/
.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,
.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);
}
.joined-buttons {
display: flex;
gap: 1px;
> .btn-wrapper:not(: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) {
: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 */
</style>

View File

@@ -191,7 +191,7 @@ const isChildOfDropdown = (element) => {
<style lang="scss" scoped>
.animated-dropdown {
width: 20rem;
min-height: 40px;
height: 40px;
position: relative;
display: inline-block;
@@ -267,6 +267,10 @@ const isChildOfDropdown = (element) => {
cursor: pointer;
user-select: none;
> label {
cursor: pointer;
}
&:hover {
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;

View File

@@ -8,8 +8,12 @@
>
<slot></slot>
<template #menu>
<template v-for="(option, index) in options">
<div v-if="option.divider" :key="`divider-${index}`" class="card-divider"></div>
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div
v-if="option.divider"
:key="`divider-${index}`"
class="h-px mx-3 my-2 bg-button-bg"
></div>
<Button
v-else
:key="`option-${option.id}`"
@@ -19,8 +23,8 @@
transparent
:action="
option.action
? () => {
option.action()
? (event) => {
option.action(event)
if (!option.remainOnClick) {
close()
}
@@ -45,29 +49,56 @@
</PopoutMenu>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import Button from './Button.vue'
import PopoutMenu from './PopoutMenu.vue'
defineProps({
options: {
type: Array,
required: true,
interface BaseOption {
shown?: boolean
}
interface Divider extends BaseOption {
divider?: boolean
}
interface Item extends BaseOption {
id: string
action?: () => void
link?: string
external?: boolean
color?:
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
hoverFilled?: boolean
hoverFilledOnly?: boolean
remainOnClick?: boolean
}
type Option = Divider | Item
const props = withDefaults(
defineProps<{
options: Option[]
disabled?: boolean
position?: string
direction?: string
}>(),
{
options: () => [],
disabled: false,
position: 'auto',
direction: 'auto',
},
disabled: {
type: Boolean,
default: false,
},
position: {
type: String,
default: 'bottom',
},
direction: {
type: String,
default: 'left',
},
})
)
defineOptions({
inheritAttrs: false,
})

View File

@@ -1,15 +1,14 @@
<template>
<div v-if="count > 1" class="paginates">
<a
:class="{ disabled: page === 1 }"
:tabindex="page === 1 ? -1 : 0"
class="left-arrow paginate has-icon"
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="page !== 1 ? switchPage(page - 1) : null"
>
<LeftArrowIcon />
</a>
<div v-if="count > 1" class="flex items-center gap-1">
<ButtonStyled v-if="page > 1" circular type="transparent">
<a
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="switchPage(page - 1)"
>
<ChevronLeftIcon />
</a>
</ButtonStyled>
<div
v-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
@@ -19,39 +18,37 @@
}"
class="page-number-container"
>
<div v-if="item === '-'" class="has-icon">
<div v-if="item === '-'">
<GapIcon />
</div>
<a
<ButtonStyled
v-else
:class="{
'page-number current': page === item,
shrink: item > 99,
}"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
circular
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
>
{{ item }}
</a>
<a :href="linkFunction(item)" @click.prevent="page !== item ? switchPage(item) : null">
{{ item }}
</a>
</ButtonStyled>
</div>
<a
:class="{
disabled: page === pages[pages.length - 1],
}"
:tabindex="page === pages[pages.length - 1] ? -1 : 0"
class="right-arrow paginate has-icon"
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="page !== pages[pages.length - 1] ? switchPage(page + 1) : null"
>
<RightArrowIcon />
</a>
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
<a
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="switchPage(page + 1)"
>
<ChevronRightIcon />
</a>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { GapIcon, LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import Button from './Button.vue'
import ButtonStyled from './ButtonStyled.vue'
const emit = defineEmits<{
'switch-page': [page: number]
@@ -61,7 +58,7 @@ const props = withDefaults(
defineProps<{
page: number
count: number
linkFunction: (page: number) => string | undefined
linkFunction?: (page: number) => string | undefined
}>(),
{
page: 1,
@@ -73,24 +70,31 @@ const props = withDefaults(
const pages = computed(() => {
let pages: ('-' | number)[] = []
if (props.count > 7) {
if (props.page + 3 >= props.count) {
pages = [
1,
'-',
props.count - 4,
props.count - 3,
props.count - 2,
props.count - 1,
props.count,
]
} else if (props.page > 5) {
pages = [1, '-', props.page - 1, props.page, props.page + 1, '-', props.count]
} else {
pages = [1, 2, 3, 4, 5, '-', props.count]
}
} else {
pages = Array.from({ length: props.count }, (_, i) => i + 1)
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)
}
return pages
@@ -100,103 +104,3 @@ function switchPage(newPage: number) {
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
}
</script>
<style lang="scss" scoped>
.paginates {
display: flex;
}
a {
color: var(--color-contrast);
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: 0.5rem 1rem;
margin: 0;
border-radius: 2rem;
background: var(--color-raised-bg);
cursor: pointer;
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;
}
&:hover {
color: inherit;
text-decoration: none;
}
&.page-number.current {
background: var(--color-brand);
color: var(--color-accent-contrast);
cursor: default;
}
&.paginate.disabled {
background-color: transparent;
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&:hover:not(&:disabled) {
filter: brightness(0.85);
}
&:active:not(&:disabled) {
transform: scale(0.95);
filter: brightness(0.8);
}
}
.has-icon {
display: flex;
align-items: center;
svg {
width: 1em;
}
}
.page-number-container,
a,
.has-icon {
display: flex;
justify-content: center;
align-items: center;
}
.paginates {
height: 2em;
margin: 0.5rem 0;
> div,
.has-icon {
margin: 0 0.3em;
}
}
.left-arrow {
margin-left: auto !important;
}
.right-arrow {
margin-right: auto !important;
}
@media screen and (max-width: 400px) {
.paginates {
font-size: 80%;
}
}
@media screen and (max-width: 530px) {
a {
width: 2.5rem;
padding: 0.5rem 0;
}
}
</style>

View File

@@ -11,7 +11,7 @@
</button>
<div
class="popup-menu"
:class="`position-${position}-${direction} ${dropdownVisible ? 'visible' : ''}`"
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
>
<slot name="menu"> </slot>
</div>
@@ -28,11 +28,11 @@ const props = defineProps({
},
position: {
type: String,
default: 'bottom',
default: 'auto',
},
direction: {
type: String,
default: 'left',
default: 'auto',
},
})
defineOptions({
@@ -42,6 +42,31 @@ defineOptions({
const dropdownVisible = ref(false)
const dropdown = ref(null)
const dropdownButton = ref(null)
const computedPosition = ref('bottom')
const computedDirection = ref('left')
function updateDirection() {
if (props.direction === 'auto') {
if (dropdownButton.value) {
const x = dropdownButton.value.getBoundingClientRect().left
computedDirection.value = x < window.innerWidth / 2 ? 'right' : 'left'
} else {
computedDirection.value = 'left'
}
} else {
computedDirection.value = props.direction
}
if (props.position === 'auto') {
if (dropdownButton.value) {
const y = dropdownButton.value.getBoundingClientRect().top
computedPosition.value = y < window.innerHeight / 2 ? 'bottom' : 'top'
} else {
computedPosition.value = 'bottom'
}
} else {
computedPosition.value = props.position
}
}
const toggleDropdown = () => {
if (!props.disabled) {
@@ -79,10 +104,15 @@ const handleClickOutside = (event) => {
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', updateDirection)
window.addEventListener('scroll', updateDirection)
updateDirection()
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateDirection)
window.removeEventListener('scroll', updateDirection)
})
</script>

View File

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

View File

@@ -0,0 +1,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>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
})
</script>
<style scoped>
:slotted(*) {
@apply h-6 w-6 text-secondary;
}
</style>

View File

@@ -21,7 +21,9 @@ export { default as Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as Promotion } from './base/Promotion.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as Slider } from './base/Slider.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as Toggle } from './base/Toggle.vue'
// Branding
@@ -51,3 +53,6 @@ export { default as SearchFilter } from './search/SearchFilter.vue'
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'

View File

@@ -1,6 +1,11 @@
<template>
<Modal ref="modal" :header="title" :noblur="noblur">
<div class="modal-delete">
<NewModal ref="modal" :noblur="noblur" danger>
<template #title>
<slot name="title">
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot>
</template>
<div>
<div class="markdown-body" v-html="renderString(description)" />
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
@@ -19,25 +24,30 @@
@input="type"
/>
</div>
<div class="input-group push-right">
<button class="btn" @click="modal.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="proceed">
<TrashIcon />
{{ proceedLabel }}
</button>
<div class="flex gap-2 mt-6">
<ButtonStyled color="red">
<button :disabled="action_disabled" @click="proceed">
<TrashIcon />
{{ proceedLabel }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { renderString } from '@modrinth/utils'
import { ref } from 'vue'
import { TrashIcon, XIcon } from '@modrinth/assets'
import Modal from './Modal.vue'
import NewModal from './NewModal.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = defineProps({
confirmationText: {
@@ -92,36 +102,3 @@ function show() {
defineExpose({ show })
</script>
<style scoped lang="scss">
.modal-delete {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
margin: 0 0.25rem;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@@ -1,5 +1,8 @@
<template>
<div v-if="open">
<div
v-if="open"
:style="`${mouseX !== -1 ? `--_mouse-x: ${mouseX};` : ''} ${mouseY !== -1 ? `--_mouse-y: ${mouseY};` : ''}`"
>
<div
:class="{ shown: visible }"
class="tauri-overlay"
@@ -10,15 +13,22 @@
:class="{
shown: visible,
noblur: props.noblur,
danger: danger,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: visible }">
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl p-6">
<div class="flex items-center pb-6 border-b-[1px] border-button-bg">
<div class="flex flex-grow items-center gap-3">
<slot name="title" />
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-button-bg max-w-full"
>
<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 @click="hide">
@@ -26,8 +36,8 @@
</button>
</ButtonStyled>
</div>
<div class="overflow-y-auto">
<slot> You just lost the game. </slot>
<div class="overflow-y-auto p-6">
<slot> You just lost the game.</slot>
</div>
</div>
</div>
@@ -35,27 +45,42 @@
<div v-else></div>
</template>
<script setup>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = defineProps({
noblur: {
type: Boolean,
default: false,
const props = withDefaults(
defineProps<{
noblur?: boolean
closable?: boolean
danger?: boolean
closeOnEsc?: boolean
warnOnClose?: boolean
header?: string
}>(),
{
type: true,
closable: true,
danger: false,
closeOnEsc: true,
warnOnClose: false,
},
closable: {
type: Boolean,
default: true,
},
})
)
const open = ref(false)
const visible = ref(false)
function show() {
function show(event?: MouseEvent) {
open.value = true
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)
@@ -63,6 +88,8 @@ function show() {
function hide() {
visible.value = false
window.removeEventListener('mousedown', updateMousePosition)
window.removeEventListener('keydown', handleKeyDown)
setTimeout(() => {
open.value = false
}, 300)
@@ -72,6 +99,22 @@ defineExpose({
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
}
function handleKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape') {
hide()
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2
}
}
</script>
<style lang="scss" scoped>
@@ -91,15 +134,18 @@ defineExpose({
}
.modal-overlay {
visibility: hidden;
position: fixed;
inset: -5rem;
z-index: 19;
opacity: 0;
transition: all 0.2s ease-out;
background: linear-gradient(to bottom, rgba(27, 48, 42, 0.52) 0%, rgba(13, 21, 26, 0.95) 100%);
transform: translateY(2rem) scale(0.8);
border-radius: 120px;
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) {
@@ -110,7 +156,7 @@ defineExpose({
opacity: 1;
visibility: visible;
backdrop-filter: blur(5px);
transform: translateY(0) scale(1);
transform: translate(0, 0) scaleX(1) scaleY(1);
border-radius: 0px;
}
@@ -118,6 +164,10 @@ defineExpose({
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%);
}
}
.modal-container {
@@ -132,13 +182,19 @@ defineExpose({
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);
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
scale: 1;
}
}
@@ -149,11 +205,11 @@ defineExpose({
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;
transform: translateY(1rem);
visibility: hidden;
opacity: 0;
transition: all 0.2s ease-in-out;

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
channel: 'release' | 'beta' | 'alpha'
large?: boolean
}>(),
{
large: false,
},
)
const messages = defineMessages({
releaseSymbol: {
id: 'project.versions.channel.release.symbol',
defaultMessage: 'R',
},
betaSymbol: {
id: 'project.versions.channel.beta.symbol',
defaultMessage: 'B',
},
alphaSymbol: {
id: 'project.versions.channel.alpha.symbol',
defaultMessage: 'A',
},
})
</script>
<template>
<div
:style="`--_size: ${size};`"
:class="`flex ${large ? 'text-lg w-[2.625rem] h-[2.625rem]' : 'text-sm w-9 h-9'} font-bold justify-center items-center rounded-full ${channel === 'release' ? 'bg-bg-green text-brand-green' : channel === 'beta' ? 'bg-bg-orange text-brand-orange' : 'bg-bg-red text-brand-red'}`"
>
{{ channel ? formatMessage(messages[`${channel}Symbol`]) : '?' }}
</div>
</template>