Migrate to Turborepo (#1251)

This commit is contained in:
Evan Song
2024-07-04 21:46:29 -07:00
committed by GitHub
parent 6fa1acc461
commit 0f2ddb452c
811 changed files with 5623 additions and 7832 deletions

View File

@@ -0,0 +1,141 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
:src="src"
:alt="alt"
:loading="loading"
@load="updatePixelated"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
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>
import { ref } from 'vue'
const pixelated = ref(false)
const img = ref(null)
defineProps({
src: {
type: String,
default: null,
},
alt: {
type: String,
default: '',
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xxs', 'xs', 'sm', 'md', 'lg', 'none'].includes(value)
},
},
circle: {
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
loading: {
type: String,
default: 'lazy',
},
raised: {
type: Boolean,
default: false,
},
})
function updatePixelated() {
pixelated.value = Boolean(img.value && img.value.naturalWidth && img.value.naturalWidth <= 96)
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--radius-md);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
height: var(--size) !important;
width: var(--size) !important;
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;
}
&.circle {
border-radius: 50%;
}
&.no-shadow {
box-shadow: none;
}
&.pixelated {
image-rendering: pixelated;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
</style>

View File

@@ -0,0 +1,253 @@
<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 /> {{ formatMessage(messages.modrinthTeamLabel) }}
</template>
<template v-else-if="type === 'moderator'">
<ScaleIcon /> {{ formatMessage(messages.moderatorLabel) }}
</template>
<template v-else-if="type === 'creator'">
<BoxIcon /> {{ formatMessage(messages.creatorLabel) }}
</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'">
<ListIcon /> {{ formatMessage(messages.listedLabel) }}
</template>
<template v-else-if="type === 'approved-general'">
<CheckIcon /> {{ formatMessage(messages.approvedLabel) }}
</template>
<template v-else-if="type === 'unlisted'">
<EyeOffIcon /> {{ formatMessage(messages.unlistedLabel) }}
</template>
<template v-else-if="type === 'withheld'">
<EyeOffIcon /> {{ formatMessage(messages.withheldLabel) }}
</template>
<template v-else-if="type === 'private'">
<LockIcon /> {{ formatMessage(messages.privateLabel) }}
</template>
<template v-else-if="type === 'scheduled'">
<CalendarIcon /> {{ formatMessage(messages.scheduledLabel) }}
</template>
<template v-else-if="type === 'draft'">
<FileTextIcon /> {{ formatMessage(messages.draftLabel) }}
</template>
<template v-else-if="type === 'archived'">
<ArchiveIcon /> {{ formatMessage(messages.archivedLabel) }}
</template>
<template v-else-if="type === 'rejected'">
<XIcon /> {{ formatMessage(messages.rejectedLabel) }}
</template>
<template v-else-if="type === 'processing'">
<UpdatedIcon /> {{ formatMessage(messages.underReviewLabel) }}
</template>
<!-- Team members -->
<template v-else-if="type === 'accepted'">
<CheckIcon /> {{ formatMessage(messages.acceptedLabel) }}
</template>
<template v-else-if="type === 'pending'">
<UpdatedIcon /> {{ formatMessage(messages.pendingLabel) }}
</template>
<!-- Transaction statuses (pending, processing reused) -->
<template v-else-if="type === 'processed'">
<CheckIcon /> {{ formatMessage(messages.processedLabel) }}
</template>
<template v-else-if="type === 'failed'">
<XIcon /> {{ formatMessage(messages.failedLabel) }}
</template>
<template v-else-if="type === 'returned'">
<XIcon /> {{ formatMessage(messages.returnedLabel) }}
</template>
<!-- Report status -->
<template v-else-if="type === 'closed'">
<XIcon /> {{ formatMessage(messages.closedLabel) }}
</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
</span>
</template>
<script setup>
import {
ModrinthIcon,
ScaleIcon,
BoxIcon,
ListIcon,
EyeOffIcon,
FileTextIcon,
XIcon,
ArchiveIcon,
UpdatedIcon,
CheckIcon,
LockIcon,
CalendarIcon,
} from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { useVIntl, defineMessages } 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',
},
})
const { formatMessage } = useVIntl()
defineProps({
type: {
type: String,
required: true,
},
color: {
type: String,
default: '',
},
})
</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);
.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;
}
&.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--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--unlisted,
&.purple {
--badge-color: var(--color-purple);
}
&.type--private,
&.gray {
--badge-color: var(--color-gray);
}
&::first-letter {
text-transform: capitalize;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup>
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,
},
})
const accentedButton = computed(() =>
['danger', 'primary', 'red', 'orange', 'green', 'blue', 'purple', 'gray'].includes(props.color)
)
</script>
<template>
<router-link
v-if="link && link.startsWith('/')"
class="btn"
:class="{
'icon-only': iconOnly,
'btn-large': 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': transparent,
'btn-hover-filled': hoverFilled,
'btn-hover-filled-only': hoverFilledOnly,
'btn-outline': outline,
'color-accent-contrast': accentedButton,
}"
:to="link"
:target="external ? '_blank' : '_self'"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</router-link>
<a
v-else-if="link"
class="btn"
:class="{
'icon-only': iconOnly,
'btn-large': 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': transparent,
'btn-hover-filled': hoverFilled,
'btn-hover-filled-only': hoverFilledOnly,
'btn-outline': outline,
'color-accent-contrast': accentedButton,
}"
:href="link"
:target="external ? '_blank' : '_self'"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
<UnknownIcon v-if="!$slots.default" />
</a>
<button
v-else
class="btn"
:class="{
'icon-only': iconOnly,
'btn-large': 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': transparent,
'btn-hover-filled': hoverFilled,
'btn-hover-filled-only': hoverFilledOnly,
'btn-outline': outline,
'color-accent-contrast': accentedButton,
}"
@click="action"
>
<slot />
<UnknownIcon v-if="!$slots.default" />
</button>
</template>
<style lang="scss" scoped>
:where(button) {
background: none;
color: var(--color-base);
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import { reactive } from 'vue'
import Button from './Button.vue'
const props = withDefaults(
defineProps<{
collapsible: boolean
defaultCollapsed: boolean
noAutoBody: boolean
}>(),
{
collapsible: false,
defaultCollapsed: false,
noAutoBody: false,
}
)
const state = reactive({
collapsed: props.defaultCollapsed,
})
function toggleCollapsed() {
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>
</template>
<style lang="scss" scoped>
.header {
display: flex;
:deep(h1, h2, h3, h4) {
margin-block: 0;
}
&:not(:last-child) {
margin-bottom: var(--gap-lg);
}
}
.btn-group {
margin-left: auto;
margin-right: 0;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div
class="checkbox-outer button-within"
:class="{ disabled }"
role="presentation"
@click="toggle"
>
<button
class="checkbox"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description"
:aria-checked="modelValue"
>
<CheckIcon v-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">
{{ label }}
</p>
<slot v-else />
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DropdownIcon } from '@modrinth/assets'
const emit = defineEmits<{
'update:modelValue': [boolean]
}>()
const props = withDefaults(
defineProps<{
label: string
disabled?: boolean
description: string
modelValue: boolean
clickEvent?: () => void
collapsingToggleStyle?: boolean
}>(),
{
label: '',
disabled: false,
description: '',
modelValue: false,
clickEvent: () => {},
collapsingToggleStyle: false,
}
)
function toggle() {
if (!props.disabled) {
emit('update:modelValue', !props.modelValue)
}
}
</script>
<style lang="scss" scoped>
.checkbox-outer {
display: flex;
align-items: center;
cursor: pointer;
p {
user-select: none;
padding: 0.2rem 0;
margin: 0;
}
&.disabled {
cursor: not-allowed;
}
}
.checkbox {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
min-width: 1rem;
min-height: 1rem;
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;
&.checked {
background-color: var(--color-brand);
}
svg {
color: var(--color-accent-contrast);
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;
flex-shrink: 0;
}
&.collapsing {
background-color: transparent !important;
box-shadow: none;
svg {
color: inherit;
height: 1rem;
width: 1rem;
transition: transform 0.25s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
&.checked {
svg {
transform: rotate(180deg);
}
}
}
&:disabled {
box-shadow: none;
cursor: not-allowed;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="chips">
<Button
v-for="item in items"
:key="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>
import { CheckIcon } from '@modrinth/assets'
</script>
<script>
import { defineComponent } from 'vue'
import Button from './Button.vue'
export default defineComponent({
props: {
modelValue: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ['update:modelValue'],
computed: {
selected: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
this.selected = this.items[0]
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null
} else {
this.selected = item
}
},
},
})
</script>
<style lang="scss" scoped>
.chips {
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
.btn {
&.capitalize {
text-transform: capitalize;
}
svg {
width: 1em;
height: 1em;
}
&: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);
}
}
</style>

View File

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

View File

@@ -0,0 +1,69 @@
<template>
<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'
const copiedMessage = defineMessage({
id: 'omorphia.component.copy.action.copy',
defaultMessage: 'Copy code to clipboard',
})
const { formatMessage } = useVIntl()
const props = defineProps<{ text: string }>()
const copied = ref(false)
async function copyText() {
await navigator.clipboard.writeText(props.text)
copied.value = true
}
</script>
<style lang="scss" scoped>
.code {
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: min-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;
}
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<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;
.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;
}
}
}
</style>

View File

@@ -0,0 +1,98 @@
<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 />
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = withDefaults(
defineProps<{
accept: string
}>(),
{
accept: '*',
}
)
const emit = defineEmits(['change'])
const dropAreaRef = ref<HTMLDivElement>()
const fileAllowed = ref(false)
const hideDropArea = () => {
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)
}
}
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()
}
}
onMounted(() => {
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);
}
@media (prefers-reduced-motion) {
transition: none !important;
}
}
</style>

View File

@@ -0,0 +1,330 @@
<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"
>
<span>{{ selectedOption }}</span>
<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}`">{{ displayName(option) }}</label>
</div>
</div>
</transition>
</div>
</div>
</template>
<script setup>
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: (option) => option,
},
})
const emit = defineEmits(['input', 'change', 'update:modelValue'])
const dropdownVisible = ref(false)
const selectedValue = ref(props.modelValue || props.defaultValue)
const focusedOptionIndex = ref(null)
const dropdown = ref(null)
const optionElements = ref(null)
const selectedOption = computed(() => {
return props.displayName(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
},
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
}
)
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
dropdown.value.focus()
}
}
const selectOption = (option, index) => {
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
}
}
const onBlur = (event) => {
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()
}
}
const focusNextOptionOrOpen = () => {
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
}
</script>
<style lang="scss" scoped>
.animated-dropdown {
width: 20rem;
min-height: 40px;
position: relative;
display: inline-block;
&:focus {
outline: 0;
}
.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;
transition: 0.05s;
&:not(.render-down):not(.render-up) {
transition-delay: 0.2s;
}
&.disabled {
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&.render-up {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
&.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;
}
.arrow {
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
}
.options {
z-index: 10;
max-height: 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;
&: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;
}
&.selected-option {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bolder;
}
input {
display: none;
}
}
}
}
.options-enter-active,
.options-leave-active {
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%);
}
&.down {
transform: translateY(-99.999%);
}
}
.options-enter-to,
.options-leave-from {
&.up {
transform: translateY(0%);
}
}
.options-wrapper {
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;
}
&.down {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
}
</style>

View File

@@ -0,0 +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>
</template>
<script setup lang="ts">
import { GlobeIcon, ClientIcon, ServerIcon, InfoIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } 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',
},
})
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: () => [],
},
)
</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;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<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>
</template>
<script>
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)
},
},
})
</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);
}
}
</style>

View File

@@ -0,0 +1,952 @@
<template>
<Modal ref="linkModal" header="Insert link">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">Label</span>
</label>
<div class="iconified-input">
<AlignLeftIcon />
<input id="insert-link-label" v-model="linkText" type="text" placeholder="Enter label..." />
<Button class="r-btn" @click="() => (linkText = '')">
<XIcon />
</Button>
</div>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="iconified-input">
<LinkIcon />
<input
id="insert-link-url"
v-model="linkUrl"
type="text"
placeholder="Enter the link's URL..."
@input="validateURL"
/>
<Button class="r-btn" @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(linkMarkdown)"
/>
</div>
<div class="input-group push-right">
<Button :action="() => linkModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="linkValidationErrorMessage || !linkUrl"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
linkModal?.hide()
}
"
><PlusIcon /> Insert</Button
>
</div>
</div>
</Modal>
<Modal ref="imageModal" header="Insert image">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">Description (alt text)<span class="required">*</span></span>
<span class="label__description">
Describe the image completely as you would to someone who could not see the image.
</span>
</label>
<div class="iconified-input">
<AlignLeftIcon />
<input
id="insert-image-alt"
v-model="linkText"
type="text"
placeholder="Describe the image..."
/>
<Button class="r-btn" @click="() => (linkText = '')">
<XIcon />
</Button>
</div>
<label class="label" for="insert-link-url">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div v-if="props.onImageUpload" class="image-strategy-chips">
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
</div>
<div
v-if="props.onImageUpload && imageUploadOption === 'upload'"
class="btn-input-alternative"
>
<FileInput
accept="image/png,image/jpeg,image/gif,image/webp"
prompt="Drag and drop to upload or click to select file"
long-style
should-always-reset
class="file-input"
@change="handleImageUpload"
>
<UploadIcon />
</FileInput>
</div>
<div v-if="!props.onImageUpload || imageUploadOption === 'link'" class="iconified-input">
<ImageIcon />
<input
id="insert-link-url"
v-model="linkUrl"
type="text"
placeholder="Enter the image URL..."
@input="validateURL"
/>
<Button class="r-btn" @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(imageMarkdown)"
/>
</div>
<div class="input-group push-right">
<Button :action="() => imageModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="!canInsertImage"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
imageModal?.hide()
}
"
>
<PlusIcon /> Insert
</Button>
</div>
</div>
</Modal>
<Modal ref="videoModal" header="Insert YouTube video">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">YouTube video URL<span class="required">*</span></span>
<span class="label__description"> Enter a valid link to a YouTube video. </span>
</label>
<div class="iconified-input">
<YouTubeIcon />
<input
id="insert-video-url"
v-model="linkUrl"
type="text"
placeholder="Enter YouTube video URL"
@input="validateURL"
/>
<Button class="r-btn" @click="() => (linkUrl = '')">
<XIcon />
</Button>
</div>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(videoMarkdown)"
/>
</div>
<div class="input-group push-right">
<Button :action="() => videoModal?.hide()"><XIcon /> Cancel</Button>
<Button
color="primary"
:disabled="linkValidationErrorMessage || !linkUrl"
:action="
() => {
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
videoModal?.hide()
}
"
>
<PlusIcon /> Insert
</Button>
</div>
</div>
</Modal>
<div class="resizable-textarea-wrapper">
<div class="editor-action-row">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label">
<Button
v-tooltip="button.label"
icon-only
:aria-label="button.label"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:action="() => button.action(editor)"
:disabled="previewMode || disabled"
>
<component :is="button.icon" />
</Button>
</template>
</template>
</div>
<div class="preview">
<Toggle id="preview" v-model="previewMode" :checked="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
<div ref="editorRef" :class="{ hide: previewMode }" />
<div v-if="!previewMode" class="info-blurb">
<div class="info-blurb">
<InfoIcon />
<span
>This editor supports
<a
class="markdown-resource-link"
href="https://docs.modrinth.com/markdown"
target="_blank"
>Markdown formatting</a
>.</span
>
</div>
<div :class="{ hide: !props.maxLength }" class="max-length-label">
<span>Max length: </span>
<span>
{{ props.maxLength ? `${currentValue?.length || 0}/${props.maxLength}` : 'Unlimited' }}
</span>
</div>
</div>
<div v-else>
<div class="markdown-body-wrapper">
<div
style="width: 100%"
class="markdown-body"
v-html="renderHighlightedString(currentValue ?? '')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type Component, computed, ref, onMounted, onBeforeUnmount, toRef, watch } from 'vue'
import { Compartment, EditorState } from '@codemirror/state'
import { EditorView, keymap, placeholder as cm_placeholder } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown'
import { indentWithTab, historyKeymap, history } from '@codemirror/commands'
import {
Heading1Icon,
Heading2Icon,
Heading3Icon,
BoldIcon,
ItalicIcon,
ScanEyeIcon,
StrikethroughIcon,
CodeIcon,
ListBulletedIcon,
ListOrderedIcon,
TextQuoteIcon,
LinkIcon,
ImageIcon,
YouTubeIcon,
AlignLeftIcon,
PlusIcon,
XIcon,
UploadIcon,
InfoIcon,
} from '@modrinth/assets'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlight'
import Modal from '../modal/Modal.vue'
import Button from './Button.vue'
import Toggle from './Toggle.vue'
import FileInput from './FileInput.vue'
import Chips from './Chips.vue'
const props = withDefaults(
defineProps<{
modelValue: string
disabled: boolean
headingButtons: boolean
/**
* @param file The file to upload
* @throws If the file is invalid or the upload fails
*/
onImageUpload?: (file: File) => Promise<string>
placeholder?: string
maxLength?: number
maxHeight?: number
}>(),
{
modelValue: '',
disabled: false,
headingButtons: true,
onImageUpload: undefined,
placeholder: 'Write something...',
maxLength: undefined,
maxHeight: undefined,
},
)
const editorRef = ref<HTMLDivElement>()
let editor: EditorView | null = null
let isDisabledCompartment: Compartment | null = null
let editorThemeCompartment: Compartment | null = null
const emit = defineEmits(['update:modelValue'])
onMounted(() => {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
updateCurrentValue(update.state.doc.toString())
}
})
editorThemeCompartment = new Compartment()
const theme = EditorView.theme({
// in defaults.scss there's references to .cm-content and such to inherit global styles
'.cm-content': {
marginBlockEnd: '0.5rem',
padding: '0.5rem',
minHeight: '200px',
caretColor: 'var(--color-contrast)',
width: '100%',
overflowX: 'scroll',
maxHeight: props.maxHeight ? `${props.maxHeight}px` : 'unset',
overflowY: 'scroll',
},
'.cm-scroller': {
height: '100%',
overflow: 'visible',
},
})
isDisabledCompartment = new Compartment()
const disabledCompartment = EditorState.readOnly.of(props.disabled)
const eventHandlers = EditorView.domEventHandlers({
paste: (ev, view) => {
const { clipboardData } = ev
if (!clipboardData) return
if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) {
// If the user is pasting a file, upload it if there's an included handler and insert the link.
uploadImagesFromList(clipboardData.files)
// eslint-disable-next-line func-names -- who the fuck did this?
.then(function (url) {
const selection = markdownCommands.yankSelection(view)
const altText = selection || 'Replace this with a description'
const linkMarkdown = `![${altText}](${url})`
return markdownCommands.replaceSelection(view, linkMarkdown)
})
.catch((error) => {
if (error instanceof Error) {
console.error('Problem with handling image.', error)
}
})
return false
}
// If the user's pasting a url, automatically convert it to a link with the selection as the text or the url itself if no selection content.
const url = ev.clipboardData?.getData('text/plain')
if (url) {
try {
cleanUrl(url)
} catch (error: unknown) {
if (error instanceof Error) {
return
}
}
const selection = view.state.selection.main
const selectionText = view.state.doc.sliceString(selection.from, selection.to)
const linkText = selectionText ? selectionText : url
const linkMarkdown = `[${linkText}](${url})`
return markdownCommands.replaceSelection(view, linkMarkdown)
}
// Check if the length of the document is greater than the max length. If it is, prevent the paste.
if (props.maxLength && view.state.doc.length > props.maxLength) {
ev.preventDefault()
return false
}
},
blur: (_, view) => {
if (props.maxLength && view.state.doc.length > props.maxLength) {
// Calculate how many characters to remove from the end
const excessLength = view.state.doc.length - props.maxLength
// Dispatch transaction to remove excess characters
view.dispatch({
changes: { from: view.state.doc.length - excessLength, to: view.state.doc.length },
selection: { anchor: props.maxLength, head: props.maxLength }, // Place cursor at the end
})
}
},
})
const inputFilter = EditorState.changeFilter.of((transaction) => {
if (props.maxLength && transaction.newDoc.length > props.maxLength) {
return false
}
return true
})
const editorState = EditorState.create({
extensions: [
eventHandlers,
updateListener,
keymap.of([indentWithTab]),
keymap.of(modrinthMarkdownEditorKeymap),
history(),
markdown({
addKeymap: false,
}),
keymap.of(historyKeymap),
cm_placeholder(props.placeholder || ''),
inputFilter,
isDisabledCompartment.of(disabledCompartment),
editorThemeCompartment.of(theme),
],
})
editor = new EditorView({
state: editorState,
parent: editorRef.value,
doc: props.modelValue ?? '', // This doesn't work for some reason
})
// set editor content to props.modelValue
editor?.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: props.modelValue,
},
})
})
onBeforeUnmount(() => {
editor?.destroy()
})
type ButtonAction = {
label: string
icon: Component
action: (editor: EditorView | null) => void
}
type ButtonGroup = {
display: boolean
hideOnMobile: boolean
buttons: ButtonAction[]
}
type ButtonGroupMap = {
[key: string]: ButtonGroup
}
function runEditorCommand(command: (view: EditorView) => boolean, editor: EditorView | null) {
if (editor) {
command(editor)
editor.focus()
}
}
const composeCommandButton = (
name: string,
icon: Component,
command: (view: EditorView) => boolean,
) => {
return {
label: name,
icon,
action: (e: EditorView | null) => runEditorCommand(command, e),
}
}
const BUTTONS: ButtonGroupMap = {
headings: {
display: props.headingButtons,
hideOnMobile: false,
buttons: [
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
],
},
stylizing: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton(
'Strikethrough',
StrikethroughIcon,
markdownCommands.toggleStrikethrough,
),
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
composeCommandButton('Spoiler', ScanEyeIcon, markdownCommands.toggleSpoiler),
],
},
lists: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
],
},
components: {
display: true,
hideOnMobile: false,
buttons: [
{
label: 'Link',
icon: LinkIcon,
action: () => openLinkModal(),
},
{
label: 'Image',
icon: ImageIcon,
action: () => openImageModal(),
},
{
label: 'Video',
icon: YouTubeIcon,
action: () => openVideoModal(),
},
],
},
}
watch(
() => props.disabled,
(newValue) => {
if (editor) {
if (isDisabledCompartment) {
editor.dispatch({
effects: [isDisabledCompartment.reconfigure(EditorState.readOnly.of(newValue))],
})
}
if (editorThemeCompartment) {
editor.dispatch({
effects: [
editorThemeCompartment.reconfigure(
EditorView.theme({
// in defaults.scss there's references to .cm-content and such to inherit global styles
'.cm-content': {
marginBlockEnd: '0.5rem',
padding: '0.5rem',
minHeight: '200px',
caretColor: 'var(--color-contrast)',
width: '100%',
overflowX: 'scroll',
maxHeight: props.maxHeight ? `${props.maxHeight}px` : 'unset',
overflowY: 'scroll',
opacity: newValue ? 0.6 : 1,
pointerEvents: newValue ? 'none' : 'all',
cursor: newValue ? 'not-allowed' : 'auto',
},
'.cm-scroller': {
height: '100%',
overflow: 'visible',
},
}),
),
],
})
}
}
},
{
immediate: true,
},
)
const currentValue = toRef(props, 'modelValue')
watch(
currentValue,
(newValue) => {
if (editor && newValue !== editor.state.doc.toString()) {
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: newValue,
},
})
}
},
{
immediate: true,
},
)
const updateCurrentValue = (newValue: string) => {
emit('update:modelValue', newValue)
}
const previewMode = ref(false)
const linkText = ref('')
const linkUrl = ref('')
const linkValidationErrorMessage = ref<string | undefined>()
function validateURL() {
if (!linkUrl.value || linkUrl.value === '') {
linkValidationErrorMessage.value = undefined
return
}
try {
linkValidationErrorMessage.value = undefined
cleanUrl(linkUrl.value)
} catch (e: unknown) {
if (e instanceof Error) {
linkValidationErrorMessage.value = e.message
}
}
}
function cleanUrl(input: string): string {
let url: URL
// Attempt to validate and parse the URL
try {
url = new URL(input)
} catch (e) {
throw new Error('Invalid URL. Make sure the URL is well-formed.')
}
// Check for unsupported protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported protocol. Use http or https.')
}
// If the scheme is "http", automatically upgrade it to "https"
if (url.protocol === 'http:') {
url.protocol = 'https:'
}
// Block certain domains for compliance
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
throw new Error('Invalid URL. This domain is not allowed.')
}
return url.toString()
}
const linkMarkdown = computed(() => {
if (!linkUrl.value) {
return ''
}
try {
const url = cleanUrl(linkUrl.value)
return url ? `[${linkText.value ? linkText.value : url}](${url})` : ''
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message)
}
}
return ''
})
const uploadImagesFromList = async (files: FileList): Promise<string> => {
const file = files[0]
if (!props.onImageUpload) {
throw new Error('No image upload handler provided')
}
if (file) {
try {
const url = await props.onImageUpload(file)
return url
} catch (error) {
if (error instanceof Error) {
console.error('Unable to upload image using handler.', error.message)
throw new Error(error.message)
}
}
}
throw new Error('No file provided')
}
const handleImageUpload = async (files: FileList) => {
if (props.onImageUpload) {
try {
const uploadedURL = await uploadImagesFromList(files)
linkUrl.value = uploadedURL
validateURL()
} catch (error) {
if (error instanceof Error) {
linkValidationErrorMessage.value = error.message
}
console.error(error)
}
}
}
const imageUploadOption = ref<string>('upload')
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
const canInsertImage = computed(() => {
// Make sure the image url is valid, there is an image url, and there is alt text
// They need to be valid, and not empty
return (
!linkValidationErrorMessage.value && linkUrl.value?.length > 0 && linkText.value?.length > 0
)
})
const youtubeRegex =
/^(?:https?:)?(?:\/\/)?(?:youtu\.be\/|(?:www\.|m\.)?youtube\.com\/(?:watch|v|embed)(?:\.php)?(?:\?.*v=|\/))(?<temp1>[a-zA-Z0-9_-]{7,15})(?:[?&][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+)*$/
const videoMarkdown = computed(() => {
const match = youtubeRegex.exec(linkUrl.value)
if (match) {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
}
return ''
})
const linkModal = ref<InstanceType<typeof Modal> | null>(null)
const imageModal = ref<InstanceType<typeof Modal> | null>(null)
const videoModal = ref<InstanceType<typeof Modal> | null>(null)
function resetModalStates() {
linkText.value = ''
linkUrl.value = ''
linkValidationErrorMessage.value = undefined
}
function openLinkModal() {
if (editor) linkText.value = markdownCommands.yankSelection(editor)
resetModalStates()
linkModal.value?.show()
}
function openImageModal() {
resetModalStates()
imageModal.value?.show()
}
function openVideoModal() {
resetModalStates()
videoModal.value?.show()
}
</script>
<style lang="scss" scoped>
.file-input {
width: 100%;
padding: 1.5rem;
padding-left: 2.5rem;
background: var(--color-button-bg);
border: 2px dashed var(--color-gray);
border-radius: var(--radius-md);
cursor: pointer;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
scale 0.05s ease-in-out,
outline 0.2s ease-in-out;
&:hover {
filter: brightness(0.85);
}
}
.markdown-resource-link {
cursor: pointer;
color: var(--color-link);
&:focus-visible,
&:hover {
filter: brightness(1.2);
text-decoration: none;
}
&:active {
filter: brightness(1.1);
text-decoration: none;
}
}
.display-options {
margin-bottom: var(--gap-sm);
}
.editor-action-row {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: var(--gap-sm);
gap: var(--gap-xs);
@media (max-width: 768px) {
flex-direction: column;
align-items: start;
}
}
.editor-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-xs);
@media (max-width: 768px) {
.divider {
display: none;
}
.mobile-hidden-group {
display: none;
}
}
.divider {
width: 0.125rem;
height: 1.8rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-max);
margin-inline: var(--gap-xs);
}
.divider:first-child {
display: none;
}
}
.resizable-textarea-wrapper textarea {
min-height: 10rem;
width: 100%;
}
.info-blurb {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-xs);
}
.hide {
display: none;
}
.preview {
display: flex;
align-items: center;
justify-items: end;
gap: var(--gap-xs);
}
.markdown-body-wrapper {
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
width: 100%;
padding: var(--radius-md);
min-height: 6rem;
}
.modal-insert {
padding: var(--gap-lg);
.iconified-input {
width: 100%;
}
.label {
margin-block: var(--gap-lg) var(--gap-sm);
display: block;
}
.label__title {
color: var(--color-contrast);
display: block;
font-size: 1.17rem;
font-weight: 700;
.required {
color: var(--color-red);
}
}
.input-group {
margin-top: var(--gap-lg);
}
}
.image-strategy-chips {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-xs);
padding-bottom: var(--gap-md);
}
.btn-input-alternative {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: var(--gap-xs);
padding-bottom: var(--gap-xs);
.btn {
width: 100%;
padding-left: 2.5rem;
min-height: 4rem;
display: flex;
align-items: center;
justify-content: start;
}
}
.cm-disabled {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="vue-notification-group">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
:key="item.id"
class="vue-notification-wrapper"
@click="notifications.splice(index, 1)"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup>
import { ref } from 'vue'
const notifications = ref([])
defineExpose({
addNotification: (notification) => {
const existingNotif = notifications.value.find(
(x) =>
x.text === notification.text &&
x.title === notification.title &&
x.type === notification.type
)
if (existingNotif) {
setNotificationTimer(existingNotif)
return
}
notification.id = new Date()
setNotificationTimer(notification)
notifications.value.push(notification)
},
})
function setNotificationTimer(notification) {
if (!notification) return
if (notification.timer) {
clearTimeout(notification.timer)
}
notification.timer = setTimeout(() => {
notifications.value.splice(notifications.value.indexOf(notification), 1)
}, 30000)
}
function stopTimer(notif) {
clearTimeout(notif.timer)
}
</script>
<style lang="scss" scoped>
.vue-notification {
background: var(--color-blue) !important;
color: var(--color-accent-contrast) !important;
box-sizing: border-box;
text-align: left;
font-size: 12px;
padding: 10px;
margin: 0 5px 5px;
&.success {
background: var(--color-green) !important;
}
&.warn {
background: var(--color-orange) !important;
}
&.error {
background: var(--color-red) !important;
}
}
.vue-notification-group {
position: fixed;
right: 25px;
bottom: 25px;
z-index: 99999999;
width: 300px;
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
margin-bottom: 10px;
.vue-notification-template {
border-radius: var(--radius-md);
margin: 0;
.notification-title {
font-size: var(--font-size-lg);
margin-right: auto;
font-weight: 600;
}
.notification-content {
margin-right: auto;
font-size: var(--font-size-md);
}
}
&:last-child {
margin: 0;
}
}
@media screen and (max-width: 750px) {
bottom: calc(var(--size-mobile-navbar-height, 15px) + 10px) !important;
&.browse-menu-open {
bottom: calc(var(--size-mobile-navbar-height-expanded, 15px) + 10px) !important;
}
}
}
.notifs-enter-active,
.notifs-leave-active,
.notifs-move {
transition: all 0.5s;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
.notifs-enter-from,
.notifs-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<PopoutMenu
ref="dropdown"
v-bind="$attrs"
:disabled="disabled"
:position="position"
:direction="direction"
>
<slot></slot>
<template #menu>
<template v-for="(option, index) in options">
<div v-if="option.divider" :key="`divider-${index}`" class="card-divider"></div>
<Button
v-else
:key="`option-${option.id}`"
:color="option.color ? option.color : 'default'"
:hover-filled="option.hoverFilled"
:hover-filled-only="option.hoverFilledOnly"
transparent
:action="
option.action
? () => {
option.action()
if (!option.remainOnClick) {
close()
}
}
: null
"
:link="option.link ? option.link : null"
:external="option.external ? option.external : false"
@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>
import { ref } from 'vue'
import Button from "./Button.vue"
import PopoutMenu from "./PopoutMenu.vue"
defineProps({
options: {
type: Array,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
position: {
type: String,
default: 'bottom',
},
direction: {
type: String,
default: 'left',
},
})
defineOptions({
inheritAttrs: false,
})
const dropdown = ref(null)
const close = () => {
console.log('closing!')
dropdown.value.hide()
}
</script>
<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;
&:not(:last-child) {
margin-bottom: var(--gap-xs);
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup>
defineProps({
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>
</template>
<style lang="scss" scoped>
.omorphia__page {
display: flex;
flex-direction: column;
padding: 0 0.75rem;
.header {
grid-area: header;
}
.sidebar {
grid-area: sidebar;
}
.footer {
grid-area: footer;
}
.content {
grid-area: content;
}
}
@media (min-width: 1024px) {
.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-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;
&.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);
}
}
}
.sidebar {
min-width: 20rem;
width: 20rem;
}
}
@media (min-width: 80rem) {
.omorphia__page.has-sidebar {
.content {
width: calc(60rem - 0.75rem);
}
}
}
</style>

View File

@@ -0,0 +1,199 @@
<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-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 === '-'" class="has-icon">
<GapIcon />
</div>
<a
v-else
:class="{
'page-number current': page === item,
shrink: item > 99,
}"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
</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>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { GapIcon, LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
const emit = defineEmits<{
'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,
}
)
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)
}
return pages
})
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

@@ -0,0 +1,213 @@
<template>
<div ref="dropdown" class="popup-container" tabindex="-1" :aria-expanded="dropdownVisible">
<button
v-bind="$attrs"
ref="dropdownButton"
:class="{ 'popout-open': dropdownVisible }"
tabindex="-1"
@click="toggleDropdown"
>
<slot></slot>
</button>
<div
class="popup-menu"
:class="`position-${position}-${direction} ${dropdownVisible ? 'visible' : ''}`"
>
<slot name="menu"> </slot>
</div>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
disabled: {
type: Boolean,
default: false,
},
position: {
type: String,
default: 'bottom',
},
direction: {
type: String,
default: 'left',
},
})
defineOptions({
inheritAttrs: false,
})
const dropdownVisible = ref(false)
const dropdown = ref(null)
const dropdownButton = ref(null)
const toggleDropdown = () => {
if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value
if (!dropdownVisible.value) {
dropdownButton.value.focus()
}
}
}
const hide = () => {
dropdownVisible.value = false
dropdownButton.value.focus()
}
const show = () => {
dropdownVisible.value = true
}
defineExpose({
show,
hide,
})
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
dropdown.value.$el !== event.target &&
!elements.includes(dropdown.value.$el) &&
!dropdown.value.contains(event.target)
) {
dropdownVisible.value = false
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
})
</script>
<style lang="scss" scoped>
.popup-container {
position: relative;
.popup-menu {
--_animation-offset: -1rem;
position: absolute;
scale: 0.75;
border: 1px solid var(--color-button-bg);
padding: var(--gap-sm);
width: fit-content;
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
z-index: 10;
opacity: 0;
transition: bottom 0.125s ease-in-out, top 0.125s ease-in-out, left 0.125s ease-in-out,
right 0.125s ease-in-out, opacity 0.125s ease-in-out, scale 0.125s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&.position-bottom-left {
top: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm) - 1rem);
right: -1rem;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm) - 1rem);
left: -1rem;
}
&.position-left-up {
bottom: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-left-down {
top: -1rem;
right: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-up {
bottom: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&.position-right-down {
top: -1rem;
left: calc(100% + var(--gap-sm) - 1rem);
}
&:not(.visible):not(:focus-within) {
pointer-events: none;
*,
::before,
::after {
pointer-events: none;
}
}
&.visible,
&:focus-within {
opacity: 1;
scale: 1;
&.position-bottom-left {
top: calc(100% + var(--gap-sm));
right: 0;
}
&.position-bottom-right {
top: calc(100% + var(--gap-sm));
left: 0;
}
&.position-top-left {
bottom: calc(100% + var(--gap-sm));
right: 0;
}
&.position-top-right {
bottom: calc(100% + var(--gap-sm));
left: 0;
}
&.position-left-up {
bottom: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-left-down {
top: 0rem;
right: calc(100% + var(--gap-sm));
}
&.position-right-up {
bottom: 0rem;
left: calc(100% + var(--gap-sm));
}
&.position-right-down {
top: 0rem;
left: calc(100% + var(--gap-sm));
}
}
.btn {
white-space: nowrap;
}
}
}
</style>

View File

@@ -0,0 +1,580 @@
<template>
<article class="project-card base-card" :aria-label="name" role="listitem">
<router-link class="icon" tabindex="-1" :to="`/${projectTypeUrl}/${id}`">
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</router-link>
<router-link
class="gallery"
:class="{ 'no-image': !featuredImage }"
tabindex="-1"
:to="`/${projectTypeUrl}/${id}`"
:style="color ? `background-color: ${toColor};` : ''"
>
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</router-link>
<div class="title">
<router-link :to="`/${projectTypeUrl}/${id}`">
<h2 class="name">
{{ name }}
</h2>
</router-link>
<p v-if="author" class="author">
by
<router-link class="title-link" :to="'/user/' + author">{{ author }} </router-link>
</p>
<Badge v-if="status && status !== 'approved'" :type="status" class="status" />
</div>
<p class="description">
{{ description }}
</p>
<Categories :categories="categories" :type="type" class="tags">
<EnvironmentIndicator
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(downloads) }}</strong
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
</p>
</div>
<div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ formatNumber(follows) }}</strong
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
</p>
</div>
<div class="buttons">
<slot />
</div>
<div v-if="showUpdatedDate" v-tooltip="updatedDate" class="stat date">
<EditIcon aria-hidden="true" />
<span class="date-label">Updated </span> {{ sinceUpdated }}
</div>
<div v-else v-tooltip="createdDate" class="stat date">
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ sinceCreation }}
</div>
</div>
</article>
</template>
<script setup>
import { HeartIcon, DownloadIcon, EditIcon, CalendarIcon } from '@modrinth/assets'
import { formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime.js'
import { defineComponent } from 'vue'
import Categories from '../search/Categories.vue'
import Badge from './Badge.vue'
import Avatar from './Avatar.vue'
import EnvironmentIndicator from './EnvironmentIndicator.vue'
</script>
<script>
dayjs.extend(relativeTime)
export default defineComponent({
props: {
id: {
type: String,
default: 'modrinth-0',
},
type: {
type: String,
default: 'mod',
},
name: {
type: String,
default: 'Project Name',
},
author: {
type: String,
default: null,
},
description: {
type: String,
default: 'A _type description',
},
iconUrl: {
type: String,
default: '#',
required: false,
},
downloads: {
type: String,
default: null,
required: false,
},
follows: {
type: String,
default: null,
required: false,
},
createdAt: {
type: String,
default: '0000-00-00',
},
updatedAt: {
type: String,
default: null,
},
categories: {
type: Array,
default() {
return []
},
},
filteredCategories: {
type: Array,
default() {
return []
},
},
projectTypeDisplay: {
type: String,
default: null,
},
projectTypeUrl: {
type: String,
default: null,
},
status: {
type: String,
default: null,
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
moderation: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
featuredImage: {
type: String,
required: false,
default: null,
},
showUpdatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,
default: false,
},
color: {
type: Number,
required: false,
default: null,
},
},
computed: {
toColor() {
let color = this.color
color >>>= 0
const b = color & 0xff
const g = (color & 0xff00) >>> 8
const r = (color & 0xff0000) >>> 16
return `rgba(${ [r, g, b, 1].join(',') })`
},
createdDate() {
return dayjs(this.createdAt).format('MMMM D, YYYY [at] h:mm:ss A')
},
sinceCreation() {
return dayjs(this.createdAt).fromNow()
},
updatedDate() {
return dayjs(this.updatedAt).format('MMMM D, YYYY [at] h:mm:ss A')
},
sinceUpdated() {
return dayjs(this.updatedAt).fromNow()
},
},
methods: {
formatNumber,
},
})
</script>
<style lang="scss" scoped>
.project-card {
display: inline-grid;
box-sizing: border-box;
overflow: hidden;
margin: 0;
line-height: 1;
}
.display-mode--list .project-card {
grid-template:
'icon title stats'
'icon description stats'
'icon tags stats';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--gap-md);
row-gap: var(--gap-sm);
width: 100%;
@media screen and (max-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
'icon title'
'icon description'
'tags tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
h2 {
margin: 0;
font-size: 1.5rem;
}
}
.display-mode--gallery .project-card,
.display-mode--grid .project-card {
padding: 0 0 1rem 0;
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--gap-sm);
.gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg);
&.no-image {
filter: brightness(0.7);
}
img {
box-shadow: none;
width: 100%;
height: 10rem;
object-fit: cover;
}
}
.icon {
margin-left: var(--gap-lg);
margin-top: -3rem;
z-index: 1;
img,
svg {
border-radius: var(--radius-lg);
box-shadow: -2px -2px 0 2px var(--color-raised-bg), 2px -2px 0 2px var(--color-raised-bg);
}
}
.title {
margin-left: var(--gap-md);
margin-right: var(--gap-md);
flex-direction: column;
.name {
font-size: 1.25rem;
}
.status {
margin-top: var(--gap-xs);
}
}
.description {
margin-inline: var(--gap-lg);
}
.tags {
margin-inline: var(--gap-lg);
}
.stats {
margin-inline: var(--gap-lg);
flex-direction: row;
align-items: center;
.stat-label {
display: none;
}
.buttons {
flex-direction: row;
gap: var(--gap-sm);
align-items: center;
> :first-child {
margin-left: auto;
}
&:first-child > :last-child {
margin-right: auto;
}
}
.buttons:not(:empty) + .date {
flex-basis: 100%;
}
}
}
.display-mode--grid .project-card {
.gallery {
display: none;
}
.icon {
margin-top: calc(var(--gap-lg) - var(--gap-sm));
img,
svg {
border: none;
}
}
.title {
margin-top: calc(var(--gap-lg) - var(--gap-sm));
}
}
.icon {
grid-area: icon;
display: flex;
align-items: center;
}
.gallery {
display: none;
height: 10rem;
grid-area: gallery;
}
.title {
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--gap-sm);
row-gap: 0;
word-wrap: anywhere;
h2 {
font-weight: bolder;
color: var(--color-contrast);
}
h2,
p {
margin: 0;
overflow-wrap: anywhere;
}
svg {
width: auto;
color: var(--color-special-orange);
height: 1.5rem;
margin-bottom: -0.25rem;
}
.title-link {
text-decoration: underline;
&:focus-visible,
&:hover {
color: var(--color-heading);
}
&:active {
color: var(--color-text-dark);
}
}
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--gap-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--gap-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--gap-md);
margin-top: var(--gap-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
.environment {
color: var(--color-text) !important;
font-weight: bold;
}
.description {
grid-area: description;
margin-block: 0;
display: flex;
justify-content: flex-start;
}
.tags {
grid-area: tags;
display: flex;
flex-direction: row;
@media screen and (max-width: 550px) {
margin-top: var(--gap-xs);
}
}
.buttons {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
align-items: flex-end;
flex-grow: 1;
}
.small-mode {
@media screen and (min-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats' !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;
.tags {
margin-top: var(--gap-xs) !important;
}
.stats {
flex-direction: row;
column-gap: var(--gap-md) !important;
margin-top: var(--gap-xs) !important;
.stat-label {
display: none !important;
}
}
}
}
.base-card {
padding: var(--gap-lg);
position: relative;
min-height: 2rem;
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
outline: 2px solid transparent;
box-shadow: var(--shadow-card);
.card__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
color: var(--color-blue);
text-decoration: underline;
}
}
&.moderation-card {
background-color: var(--color-banner-bg);
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<div class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD">
<a
:href="`https://bisecthosting.com/modrinth${queryParam}`"
rel="noopener nofollow sponsored"
:target="target"
></a>
<div class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-0">
<div class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-1">
<div class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2">
<BisectIcon class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-3" />
<span>
<span> Host your Minecraft server on </span>
<strong>BisectHosting</strong>
<span> - get 25% off your first month with code <strong>MODRINTH</strong>. </span>
</span>
</div>
</div>
<div class="GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-4">
<a rel="noopener sponsored" :target="target" href="https://adrinth.com"> Ad via Adrinth </a>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { BisectIcon } from '@modrinth/assets'
const props = withDefaults(
defineProps<{
external: boolean
queryParam: string
}>(),
{
external: true,
queryParam: '',
}
)
const target = computed(() => (props.external ? '_blank' : '_self'))
</script>
<style lang="scss" scoped>
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD {
position: relative;
margin-bottom: var(--gap-md);
background: var(--color-ad);
border: 1px solid var(--color-ad-raised);
border-radius: var(--radius-lg);
container-type: inline-size;
width: 100%;
> a {
position: absolute;
inset: 0;
}
&:has(> a:first-child:active) {
scale: 0.99;
}
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-0 {
font-size: 14px;
line-height: 1.3em;
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-1 {
color: var(--color-base);
padding: 1em;
text-align: left;
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2 {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-base);
margin-right: 7.5rem;
&:hover {
text-decoration: none;
}
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2 b,
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2 strong {
color: var(--color-ad-highlight);
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-3 {
padding-top: 1px;
height: 1.5rem;
width: auto;
flex-shrink: 0;
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-4 a {
position: absolute;
bottom: -1px;
right: -1px;
text-align: center;
font-weight: 600;
text-transform: uppercase;
font-size: 0.8em;
color: var(--color-ad-contrast);
background: var(--color-ad-raised);
letter-spacing: 0.1ch;
margin: 0;
padding: 2px 10px;
border-top-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
display: flex;
align-items: center;
gap: 0.5rem;
&:hover {
text-decoration: none;
}
}
@media screen and (max-width: 800px) {
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2 {
margin-bottom: 0.5rem;
}
.GBBNWLJVGRHFLYVGSZKSSKNTHFYXHMBD-2 a {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,245 @@
<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 }"
: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="text"
class="slider-input"
:disabled="disabled"
@change="onInput(($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<script setup lang="ts">
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
}
const props = withDefaults(defineProps<Props>(), {
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 = (newValue: number) => {
if (newValue < props.min) {
currentValue.value = props.min
} else if (newValue > props.max) {
currentValue.value = props.max
} else if (!newValue) {
currentValue.value = props.min
} else {
currentValue.value = newValue - (props.forceStep ? newValue % props.step : 0)
}
emit('update:modelValue', currentValue.value)
}
const onInputWithSnap = (value: string) => {
let parsedValue = parseInt(value)
for (const snapPoint of props.snapPoints) {
const distance = Math.abs(snapPoint - parsedValue)
if (distance < props.snapRange) {
parsedValue = snapPoint
}
}
inputValueValid(parsedValue)
}
const onInput = (value: string) => {
inputValueValid(parseInt(value))
}
</script>
<style lang="scss" scoped>
.root-container {
--transition-speed: 0.2s;
@media (prefers-reduced-motion) {
--transition-speed: 0s;
}
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
.slider-component,
.slide-container {
width: 100%;
position: relative;
}
.slider-component .slide-container .slider {
-webkit-appearance: none;
appearance: none;
position: relative;
border-radius: var(--radius-sm);
height: 0.25rem;
width: 100%;
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);
}
.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);
}
.slider-component .slide-container .slider:hover::-webkit-slider-thumb:not(.disabled) {
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);
}
.slider-component .slide-container .snap-points-wrapper {
position: absolute;
height: 50%;
width: 100%;
.snap-points {
position: relative;
display: inline-block;
vertical-align: middle;
width: calc(100% - 0.75rem);
height: 0.75rem;
left: calc(0.75rem / 2);
.snap-point {
position: absolute;
display: inline-block;
width: 0.25rem;
height: 100%;
border-radius: var(--radius-sm);
background-color: var(--color-base);
transform: translateX(calc(-0.25rem / 2));
&.green {
background-color: var(--color-brand);
}
}
}
}
.slider-input {
width: 6rem;
margin-left: 0.75rem;
}
.slider-range {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 0.75rem;
margin: 0;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<input
:id="id"
type="checkbox"
class="switch stylized-toggle"
:checked="checked"
@change="toggle"
/>
</template>
<script>
export default {
props: {
id: {
type: String,
required: true,
},
modelValue: {
type: Boolean,
},
checked: {
type: Boolean,
required: true,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('update:modelValue', !this.modelValue)
}
},
},
}
</script>