You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
141
packages/ui/src/components/base/Avatar.vue
Normal file
141
packages/ui/src/components/base/Avatar.vue
Normal 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>
|
||||
253
packages/ui/src/components/base/Badge.vue
Normal file
253
packages/ui/src/components/base/Badge.vue
Normal 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>
|
||||
146
packages/ui/src/components/base/Button.vue
Normal file
146
packages/ui/src/components/base/Button.vue
Normal 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>
|
||||
59
packages/ui/src/components/base/Card.vue
Normal file
59
packages/ui/src/components/base/Card.vue
Normal 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>
|
||||
133
packages/ui/src/components/base/Checkbox.vue
Normal file
133
packages/ui/src/components/base/Checkbox.vue
Normal 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>
|
||||
101
packages/ui/src/components/base/Chips.vue
Normal file
101
packages/ui/src/components/base/Chips.vue
Normal 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>
|
||||
21
packages/ui/src/components/base/ConditionalNuxtLink.vue
Normal file
21
packages/ui/src/components/base/ConditionalNuxtLink.vue
Normal 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>
|
||||
69
packages/ui/src/components/base/CopyCode.vue
Normal file
69
packages/ui/src/components/base/CopyCode.vue
Normal 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>
|
||||
34
packages/ui/src/components/base/DoubleIcon.vue
Normal file
34
packages/ui/src/components/base/DoubleIcon.vue
Normal 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>
|
||||
98
packages/ui/src/components/base/DropArea.vue
Normal file
98
packages/ui/src/components/base/DropArea.vue
Normal 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>
|
||||
330
packages/ui/src/components/base/DropdownSelect.vue
Normal file
330
packages/ui/src/components/base/DropdownSelect.vue
Normal 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>
|
||||
114
packages/ui/src/components/base/EnvironmentIndicator.vue
Normal file
114
packages/ui/src/components/base/EnvironmentIndicator.vue
Normal 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>
|
||||
107
packages/ui/src/components/base/FileInput.vue
Normal file
107
packages/ui/src/components/base/FileInput.vue
Normal 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>
|
||||
952
packages/ui/src/components/base/MarkdownEditor.vue
Normal file
952
packages/ui/src/components/base/MarkdownEditor.vue
Normal 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 = ``
|
||||
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>
|
||||
141
packages/ui/src/components/base/Notifications.vue
Normal file
141
packages/ui/src/components/base/Notifications.vue
Normal 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>
|
||||
96
packages/ui/src/components/base/OverflowMenu.vue
Normal file
96
packages/ui/src/components/base/OverflowMenu.vue
Normal 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>
|
||||
121
packages/ui/src/components/base/Page.vue
Normal file
121
packages/ui/src/components/base/Page.vue
Normal 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>
|
||||
199
packages/ui/src/components/base/Pagination.vue
Normal file
199
packages/ui/src/components/base/Pagination.vue
Normal 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>
|
||||
213
packages/ui/src/components/base/PopoutMenu.vue
Normal file
213
packages/ui/src/components/base/PopoutMenu.vue
Normal 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>
|
||||
580
packages/ui/src/components/base/ProjectCard.vue
Normal file
580
packages/ui/src/components/base/ProjectCard.vue
Normal 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>
|
||||
125
packages/ui/src/components/base/Promotion.vue
Normal file
125
packages/ui/src/components/base/Promotion.vue
Normal 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>
|
||||
245
packages/ui/src/components/base/Slider.vue
Normal file
245
packages/ui/src/components/base/Slider.vue
Normal 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>
|
||||
35
packages/ui/src/components/base/Toggle.vue
Normal file
35
packages/ui/src/components/base/Toggle.vue
Normal 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>
|
||||
98
packages/ui/src/components/brand/AnimatedLogo.vue
Normal file
98
packages/ui/src/components/brand/AnimatedLogo.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg
|
||||
class="rotate outer"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
class="rotate inner"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 590 591"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
|
||||
<g transform="matrix(0.24,0,0,0.24,0,0)">
|
||||
<path
|
||||
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
|
||||
style="fill: var(--color-brand)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div {
|
||||
height: 5rem;
|
||||
|
||||
svg {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
position: absolute;
|
||||
&.rotate {
|
||||
animation: rotate 4s infinite linear;
|
||||
&.inner {
|
||||
animation: rotate 6s infinite linear reverse;
|
||||
}
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
packages/ui/src/components/brand/TextLogo.vue
Normal file
65
packages/ui/src/components/brand/TextLogo.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 3307 593"
|
||||
:class="{ animate }"
|
||||
>
|
||||
<path
|
||||
fill-rule="nonzero"
|
||||
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<g fill="var(--color-brand)">
|
||||
<path
|
||||
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
|
||||
/>
|
||||
<path
|
||||
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
|
||||
class="ring ring--large"
|
||||
/>
|
||||
<path
|
||||
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
|
||||
class="ring ring--small"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
animate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animate {
|
||||
.ring {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation-fill-mode: forwards;
|
||||
transition: transform 2s ease-in-out;
|
||||
&--large {
|
||||
animation: spin 1s ease-in-out infinite forwards;
|
||||
}
|
||||
&--small {
|
||||
animation: spin 2s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
421
packages/ui/src/components/chart/Chart.vue
Normal file
421
packages/ui/src/components/chart/Chart.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { formatNumber } from '@modrinth/utils'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import Button from '../base/Button.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
formatLabels: {
|
||||
type: Function,
|
||||
default: (label) => dayjs(label).format('MMM D'),
|
||||
},
|
||||
colors: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
'var(--color-brand)',
|
||||
'var(--color-blue)',
|
||||
'var(--color-purple)',
|
||||
'var(--color-red)',
|
||||
'var(--color-orange)',
|
||||
],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hideToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideLegend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'bar',
|
||||
},
|
||||
hideTotal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const chartOptions = ref({
|
||||
chart: {
|
||||
id: props.name,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
selection: {
|
||||
enabled: true,
|
||||
fill: {
|
||||
color: 'var(--color-brand)',
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
stacked: props.stacked,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
style: {
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
},
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
colors: props.colors,
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
background: {
|
||||
enabled: true,
|
||||
borderRadius: 20,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
borderColor: 'var(--color-button-bg)',
|
||||
tickColor: 'var(--color-button-bg)',
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
strokeColor: 'var(--color-contrast)',
|
||||
strokeWidth: 3,
|
||||
strokeOpacity: 1,
|
||||
fillOpacity: 1,
|
||||
hover: {
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
bar: {
|
||||
columnWidth: '80%',
|
||||
endingShape: 'rounded',
|
||||
borderRadius: 5,
|
||||
borderRadiusApplication: 'end',
|
||||
borderRadiusWhenStacked: 'last',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom ({ series, seriesIndex, dataPointIndex, w }) {
|
||||
console.log(seriesIndex, w)
|
||||
return (
|
||||
`<div class="bar-tooltip">` +
|
||||
`<div class="seperated-entry title">` +
|
||||
`<div class="label">${
|
||||
props.formatLabels(w.globals.lastXAxis.categories[dataPointIndex])
|
||||
}</div>${
|
||||
!props.hideTotal
|
||||
? `<div class="value">
|
||||
${props.prefix}
|
||||
${formatNumber(series.reduce((a, b) => a + b[dataPointIndex], 0).toString(), false)}
|
||||
${props.suffix}
|
||||
</div>`
|
||||
: ``
|
||||
}</div><hr class="card-divider" />${
|
||||
series
|
||||
.map((value, index) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
<span class="circle" style="background-color: ${w.globals.colors[index]}"> </span>
|
||||
<div class="label">
|
||||
${w.globals.seriesNames[index]}
|
||||
</div>
|
||||
<div class="value">
|
||||
${props.prefix}
|
||||
${formatNumber(value[dataPointIndex], false)}
|
||||
${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
: ''
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)
|
||||
}</div>`
|
||||
)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const chart = ref(null)
|
||||
|
||||
const legendValues = ref(
|
||||
[...props.data].map((project, index) => {
|
||||
return { name: project.name, visible: true, color: props.colors[index] }
|
||||
})
|
||||
)
|
||||
|
||||
const flipLegend = (legend, newVal) => {
|
||||
legend.visible = newVal
|
||||
chart.value.toggleSeries(legend.name)
|
||||
}
|
||||
|
||||
const downloadCSV = () => {
|
||||
const csvContent =
|
||||
`data:text/csv;charset=utf-8,${
|
||||
props.labels.join(',')
|
||||
}\n${
|
||||
props.data.map((project) => project.data.join(',')).reduce((a, b) => `${a }\n${ b}`)}`
|
||||
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('href', encodedUri)
|
||||
link.setAttribute('download', `${props.name}.csv`)
|
||||
document.body.appendChild(link) // Required for FF
|
||||
|
||||
link.click()
|
||||
}
|
||||
|
||||
const resetChart = () => {
|
||||
chart.value.resetSeries()
|
||||
legendValues.value.forEach((legend) => {
|
||||
legend.visible = true
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
resetChart,
|
||||
downloadCSV,
|
||||
flipLegend,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-chart">
|
||||
<div class="title-bar">
|
||||
<slot />
|
||||
<div v-if="!hideToolbar" class="toolbar">
|
||||
<Button v-tooltip="'Download data as CSV'" icon-only @click="downloadCSV">
|
||||
<!-- <DownloadIcon /> -->
|
||||
</Button>
|
||||
<Button v-tooltip="'Reset chart'" icon-only @click="resetChart">
|
||||
<!-- <UpdatedIcon /> -->
|
||||
</Button>
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
|
||||
<div v-if="!hideLegend" class="legend">
|
||||
<Checkbox
|
||||
v-for="legend in legendValues"
|
||||
:key="legend.name"
|
||||
class="legend-checkbox"
|
||||
:style="`--color: ${legend.color};`"
|
||||
:model-value="legend.visible"
|
||||
@update:model-value="(newVal) => flipLegend(legend, newVal)"
|
||||
>
|
||||
{{ legend.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
z-index: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip),
|
||||
:deep(.apexcharts-xaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
|
||||
.apexcharts-xaxistooltip-text {
|
||||
font-size: var(--font-size-nm) !important;
|
||||
color: var(--color-base) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:after) {
|
||||
border-left-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-yaxistooltip-left:before) {
|
||||
border-left-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:after) {
|
||||
border-bottom-color: var(--color-raised-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxistooltip-bottom:before) {
|
||||
border-bottom-color: var(--color-button-bg) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu-item) {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
padding: var(--gap-xs) var(--gap-sm) !important;
|
||||
|
||||
&:hover {
|
||||
transition: all 0.3s !important;
|
||||
color: var(--color-accent-contrast) !important;
|
||||
background: var(--color-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.seperated-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
.value {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: var(--gap-xl);
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
278
packages/ui/src/components/chart/CompactChart.vue
Normal file
278
packages/ui/src/components/chart/CompactChart.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<!-- eslint-disable eslint-comments/require-description -->
|
||||
<script setup>
|
||||
import { formatNumber } from '@modrinth/utils'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import Card from '../base/Card.vue'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
prefix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
suffix: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
//no grid lines, no toolbar, no legend, no data labels
|
||||
const chartOptions = ref({
|
||||
chart: {
|
||||
id: props.title,
|
||||
fontFamily:
|
||||
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
|
||||
foreColor: 'var(--color-base)',
|
||||
toolbar: {
|
||||
show: false,
|
||||
},
|
||||
zoom: {
|
||||
enabled: false,
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
curve: 'straight',
|
||||
},
|
||||
fill: {
|
||||
colors: ['var(--color-brand)'],
|
||||
type: 'gradient',
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: ['var(--color-brand)'],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
colors: ['var(--color-brand)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
xaxis: {
|
||||
type: 'datetime',
|
||||
categories: props.labels,
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
show: false,
|
||||
},
|
||||
axisBorder: {
|
||||
show: false,
|
||||
},
|
||||
axisTicks: {
|
||||
show: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
custom({ series, seriesIndex, dataPointIndex, w }) {
|
||||
|
||||
console.log(seriesIndex, w)
|
||||
return `<div class="bar-tooltip">${series
|
||||
.map((value) =>
|
||||
value[dataPointIndex] > 0
|
||||
? `<div class="list-entry">
|
||||
<div class="label">
|
||||
<span class="circle" style="background-color: ${w.globals.colors[0]}"> </span>
|
||||
${dayjs(w.globals.lastXAxis.categories[dataPointIndex]).format('MMM D')}
|
||||
</div>
|
||||
<div class="divider">
|
||||
|
|
||||
</div>
|
||||
<div class="value">
|
||||
${props.prefix}
|
||||
${formatNumber(value[dataPointIndex], false)}
|
||||
${props.suffix}
|
||||
</div>
|
||||
</div>`
|
||||
: ''
|
||||
)
|
||||
.reverse()
|
||||
.reduce((a, b) => a + b)}</div>`
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="compact-chart">
|
||||
<h1 class="value">
|
||||
{{ value }}
|
||||
</h1>
|
||||
<div class="subtitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
<VueApexCharts
|
||||
ref="chart"
|
||||
type="area"
|
||||
height="120"
|
||||
:options="chartOptions"
|
||||
:series="data"
|
||||
class="chart"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.compact-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-floating);
|
||||
color: var(--color-base);
|
||||
font-size: var(--font-size-nm);
|
||||
width: 100%;
|
||||
padding-bottom: 0;
|
||||
|
||||
.value {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: calc(100% + 3rem);
|
||||
margin: 0 -1.5rem 0.25rem -1.5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-menu),
|
||||
:deep(.apexcharts-tooltip),
|
||||
:deep(.apexcharts-yaxistooltip) {
|
||||
background: var(--color-raised-bg) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
border: 1px solid var(--color-button-bg) !important;
|
||||
box-shadow: var(--shadow-floating) !important;
|
||||
font-size: var(--font-size-nm) !important;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-graphical) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-tooltip) {
|
||||
.bar-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.card-divider {
|
||||
margin: var(--gap-xs) 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: var(--gap-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:deep(.apexcharts-grid-borders) {
|
||||
line {
|
||||
stroke: var(--color-button-bg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.apexcharts-xaxis) {
|
||||
line {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-checkbox :deep(.checkbox.checked) {
|
||||
background-color: var(--color);
|
||||
}
|
||||
</style>
|
||||
49
packages/ui/src/components/index.ts
Normal file
49
packages/ui/src/components/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Base content
|
||||
export { default as Avatar } from './base/Avatar.vue'
|
||||
export { default as Badge } from './base/Badge.vue'
|
||||
export { default as Button } from './base/Button.vue'
|
||||
export { default as Card } from './base/Card.vue'
|
||||
export { default as Checkbox } from './base/Checkbox.vue'
|
||||
export { default as Chips } from './base/Chips.vue'
|
||||
export { default as ConditionalNuxtLink } from './base/ConditionalNuxtLink.vue'
|
||||
export { default as CopyCode } from './base/CopyCode.vue'
|
||||
export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
||||
export { default as DropArea } from './base/DropArea.vue'
|
||||
export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
export { default as Notifications } from './base/Notifications.vue'
|
||||
export { default as OverflowMenu } from './base/OverflowMenu.vue'
|
||||
export { default as Page } from './base/Page.vue'
|
||||
export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as PopoutMenu } from './base/PopoutMenu.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as Promotion } from './base/Promotion.vue'
|
||||
export { default as Slider } from './base/Slider.vue'
|
||||
export { default as Toggle } from './base/Toggle.vue'
|
||||
|
||||
// Branding
|
||||
export { default as AnimatedLogo } from './brand/AnimatedLogo.vue'
|
||||
export { default as TextLogo } from './brand/TextLogo.vue'
|
||||
|
||||
// Charts
|
||||
export { default as Chart } from './chart/Chart.vue'
|
||||
export { default as CompactChart } from './chart/CompactChart.vue'
|
||||
|
||||
// Modals
|
||||
export { default as Modal } from './modal/Modal.vue'
|
||||
export { default as ConfirmModal } from './modal/ConfirmModal.vue'
|
||||
export { default as ReportModal } from './modal/ReportModal.vue'
|
||||
export { default as ShareModal } from './modal/ShareModal.vue'
|
||||
|
||||
// Navigation
|
||||
export { default as Breadcrumbs } from './nav/Breadcrumbs.vue'
|
||||
export { default as NavItem } from './nav/NavItem.vue'
|
||||
export { default as NavRow } from './nav/NavRow.vue'
|
||||
export { default as NavStack } from './nav/NavStack.vue'
|
||||
|
||||
// Search
|
||||
export { default as Categories } from './search/Categories.vue'
|
||||
export { default as SearchDropdown } from './search/SearchDropdown.vue'
|
||||
export { default as SearchFilter } from './search/SearchFilter.vue'
|
||||
127
packages/ui/src/components/modal/ConfirmModal.vue
Normal file
127
packages/ui/src/components/modal/ConfirmModal.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Modal ref="modal" :header="title" :noblur="noblur">
|
||||
<div class="modal-delete">
|
||||
<div class="markdown-body" v-html="renderString(description)" />
|
||||
<label v-if="hasToType" for="confirmation" class="confirmation-label">
|
||||
<span>
|
||||
<strong>To verify, type</strong>
|
||||
<em class="confirmation-text">{{ confirmationText }}</em>
|
||||
<strong>below:</strong>
|
||||
</span>
|
||||
</label>
|
||||
<div class="confirmation-input">
|
||||
<input
|
||||
v-if="hasToType"
|
||||
id="confirmation"
|
||||
v-model="confirmation_typed"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
@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>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'No description defined',
|
||||
required: true,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
const modal = ref(null)
|
||||
|
||||
const action_disabled = ref(props.hasToType)
|
||||
const confirmation_typed = ref('')
|
||||
|
||||
function proceed() {
|
||||
modal.value.hide()
|
||||
emit('proceed')
|
||||
}
|
||||
|
||||
function type() {
|
||||
if (props.hasToType) {
|
||||
action_disabled.value =
|
||||
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value.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>
|
||||
180
packages/ui/src/components/modal/Modal.vue
Normal file
180
packages/ui/src/components/modal/Modal.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div v-if="shown">
|
||||
<div
|
||||
:class="{ shown: actuallyShown }"
|
||||
class="tauri-overlay"
|
||||
data-tauri-drag-region
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: actuallyShown,
|
||||
noblur: props.noblur,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="() => (closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="props.header" class="header">
|
||||
<h1>{{ props.header }}</h1>
|
||||
<button v-if="closable" class="btn icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const shown = ref(false)
|
||||
const actuallyShown = ref(false)
|
||||
|
||||
function show() {
|
||||
shown.value = true
|
||||
setTimeout(() => {
|
||||
actuallyShown.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
actuallyShown.value = false
|
||||
setTimeout(() => {
|
||||
shown.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tauri-overlay {
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
z-index: 20;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 19;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 21;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
|
||||
&.shown {
|
||||
visibility: visible;
|
||||
.modal-body {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
border-radius: var(--radius-lg);
|
||||
max-height: calc(100% - 2 * var(--gap-lg));
|
||||
overflow-y: auto;
|
||||
width: 600px;
|
||||
pointer-events: auto;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
transform: translateY(50vh);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
packages/ui/src/components/modal/ReportModal.vue
Normal file
127
packages/ui/src/components/modal/ReportModal.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Modal ref="modal" :header="`Report ${itemType}`" :noblur="noblur">
|
||||
<div class="modal-report universal-labels">
|
||||
<div class="markdown-body">
|
||||
<p>
|
||||
Modding should be safe for everyone, so we take abuse and malicious intent seriously at
|
||||
Modrinth. We want to hear about harmful content on the site that violates our
|
||||
<router-link to="/legal/terms">ToS</router-link> and
|
||||
<router-link to="/legal/rules">Rules</router-link>. Rest assured, we'll keep your
|
||||
identifying information private.
|
||||
</p>
|
||||
<p v-if="itemType === 'project' || itemType === 'version'">
|
||||
Please <strong>do not</strong> use this to report bugs with the project itself. This form
|
||||
is only for submitting a report to Modrinth staff. If the project has an Issues link or a
|
||||
Discord invite, consider reporting it there.
|
||||
</p>
|
||||
</div>
|
||||
<label for="report-type">
|
||||
<span class="label__title">Reason</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
name="report-type"
|
||||
:options="reportTypes"
|
||||
:display-name="capitalizeString"
|
||||
default-value="Choose report type"
|
||||
class="multiselect"
|
||||
/>
|
||||
<label for="report-body">
|
||||
<span class="label__title">Additional information</span>
|
||||
<span class="label__description markdown-body">
|
||||
Please provide additional context about your report. Include links and images if possible.
|
||||
<strong>Empty reports will be closed.</strong> This editor supports
|
||||
<a href="https://docs.modrinth.com/markdown" target="_blank">Markdown formatting</a>.
|
||||
</span>
|
||||
</label>
|
||||
<Chips v-model="bodyViewType" class="separator" :items="['source', 'preview']" />
|
||||
<div class="text-input textarea-wrapper">
|
||||
<textarea v-if="bodyViewType === 'source'" id="body" v-model="body" spellcheck="true" />
|
||||
<div v-else class="preview" v-html="renderString(body)" />
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<Button @click="cancel">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" @click="submitReport">
|
||||
<CheckIcon />
|
||||
Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { capitalizeString, renderString } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import Chips from '../base/Chips.vue'
|
||||
import DropdownSelect from '../base/DropdownSelect.vue'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
defineProps({
|
||||
itemType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
itemId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
reportTypes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
submitReport: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const reportType = ref('')
|
||||
const body = ref('')
|
||||
const bodyViewType = ref('source')
|
||||
|
||||
const modal = ref(null)
|
||||
|
||||
function cancel() {
|
||||
reportType.value = ''
|
||||
body.value = ''
|
||||
bodyViewType.value = 'source'
|
||||
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-report {
|
||||
padding: var(--gap-lg);
|
||||
|
||||
.textarea-wrapper {
|
||||
height: 10rem;
|
||||
|
||||
:first-child {
|
||||
max-height: 8rem;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
273
packages/ui/src/components/modal/ShareModal.vue
Normal file
273
packages/ui/src/components/modal/ShareModal.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
ShareIcon,
|
||||
MailIcon,
|
||||
GlobeIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
RedditIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { Button, Modal } from '../index'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const shareModal = ref(null)
|
||||
|
||||
const qrCode = ref(null)
|
||||
const qrImage = ref(null)
|
||||
const content = ref(null)
|
||||
const url = ref(null)
|
||||
const canShare = ref(false)
|
||||
const share = () => {
|
||||
navigator.share(
|
||||
props.link
|
||||
? {
|
||||
title: props.shareTitle.toString(),
|
||||
text: props.shareText,
|
||||
url: url.value,
|
||||
}
|
||||
: {
|
||||
title: props.shareTitle.toString(),
|
||||
text: content.value,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const show = async (passedContent) => {
|
||||
content.value = props.shareText ? `${props.shareText}\n\n${passedContent}` : passedContent
|
||||
shareModal.value.show()
|
||||
if (props.link) {
|
||||
url.value = passedContent
|
||||
nextTick(() => {
|
||||
console.log(qrCode.value)
|
||||
fetch(qrCode.value.getElementsByTagName('canvas')[0].toDataURL('image/png'))
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
console.log(blob)
|
||||
qrImage.value = blob
|
||||
})
|
||||
})
|
||||
}
|
||||
if (navigator.canShare({ title: props.shareTitle.toString(), text: content.value })) {
|
||||
canShare.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const copyImage = async () => {
|
||||
const item = new ClipboardItem({ 'image/png': qrImage.value })
|
||||
await navigator.clipboard.write([item])
|
||||
}
|
||||
|
||||
const copyText = async () => {
|
||||
await navigator.clipboard.writeText(url.value ?? content.value)
|
||||
}
|
||||
|
||||
const sendEmail = computed(
|
||||
() =>
|
||||
`mailto:user@test.com
|
||||
?subject=${encodeURIComponent(props.shareTitle)}
|
||||
&body=${ encodeURIComponent(content.value)}`
|
||||
)
|
||||
|
||||
const sendTweet = computed(
|
||||
() => `https://twitter.com/intent/tweet?text=${ encodeURIComponent(content.value)}`
|
||||
)
|
||||
|
||||
const sendToot = computed(() => `https://tootpick.org/#text=${ encodeURIComponent(content.value)}`)
|
||||
|
||||
const postOnReddit = computed(
|
||||
() =>
|
||||
`https://www.reddit.com/submit?title=${encodeURIComponent(props.shareTitle)}&text=${
|
||||
encodeURIComponent(content.value)}`
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal ref="shareModal" :header="header">
|
||||
<div class="share-body">
|
||||
<div v-if="link" class="qr-wrapper">
|
||||
<div ref="qrCode">
|
||||
<QrcodeVue :value="url" class="qr-code" margin="3" />
|
||||
</div>
|
||||
<Button v-tooltip="'Copy QR code'" icon-only class="copy-button" @click="copyImage">
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="resizable-textarea-wrapper">
|
||||
<textarea v-model="content" />
|
||||
<Button v-tooltip="'Copy Text'" icon-only class="copy-button transparent" @click="copyText">
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="all-buttons">
|
||||
<div v-if="link" class="iconified-input">
|
||||
<LinkIcon />
|
||||
<input type="text" :value="url" readonly />
|
||||
<Button v-tooltip="'Copy Text'" class="r-btn" @click="copyText">
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button v-if="canShare" v-tooltip="'Share'" icon-only @click="share">
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<a v-tooltip="'Send as an email'" class="btn icon-only" target="_blank" :href="sendEmail">
|
||||
<MailIcon />
|
||||
</a>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
class="btn icon-only"
|
||||
target="_blank"
|
||||
:href="url"
|
||||
>
|
||||
<GlobeIcon />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
class="btn mastodon icon-only"
|
||||
target="_blank"
|
||||
:href="sendToot"
|
||||
>
|
||||
<MastodonIcon />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
class="btn twitter icon-only"
|
||||
target="_blank"
|
||||
:href="sendTweet"
|
||||
>
|
||||
<TwitterIcon />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
class="btn reddit icon-only"
|
||||
target="_blank"
|
||||
:href="postOnReddit"
|
||||
>
|
||||
<RedditIcon />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-lg);
|
||||
}
|
||||
|
||||
.all-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
|
||||
input {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.btn {
|
||||
fill: var(--color-contrast);
|
||||
color: var(--color-contrast);
|
||||
|
||||
&.reddit {
|
||||
background-color: #ff4500;
|
||||
}
|
||||
|
||||
&.mastodon {
|
||||
background-color: #563acc;
|
||||
}
|
||||
|
||||
&.twitter {
|
||||
background-color: #1da1f2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
|
||||
&:hover {
|
||||
.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
background-color: white !important;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: var(--gap-sm);
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-textarea-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
opacity: 1;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
52
packages/ui/src/components/nav/Breadcrumbs.vue
Normal file
52
packages/ui/src/components/nav/Breadcrumbs.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<nav class="breadcrumbs">
|
||||
<template v-for="(link, index) in linkStack" :key="index">
|
||||
<RouterLink
|
||||
:to="link.href"
|
||||
class="breadcrumb goto-link"
|
||||
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
<ChevronRightIcon />
|
||||
</template>
|
||||
<span class="breadcrumb">{{ currentTitle }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps({
|
||||
linkStack: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
margin-bottom: var(--gap-lg);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
a.breadcrumb {
|
||||
padding-block: var(--gap-xs);
|
||||
&.trim {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
50
packages/ui/src/components/nav/NavItem.vue
Normal file
50
packages/ui/src/components/nav/NavItem.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import Button from '../base/Button.vue'
|
||||
|
||||
defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
external: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
:link="link"
|
||||
:external="external"
|
||||
:action="action"
|
||||
design="nav"
|
||||
class="quiet-disabled"
|
||||
:class="{
|
||||
selected: selected,
|
||||
}"
|
||||
:disabled="selected"
|
||||
:navlabel="label"
|
||||
>
|
||||
<slot />
|
||||
{{ label }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
166
packages/ui/src/components/nav/NavRow.vue
Normal file
166
packages/ui/src/components/nav/NavRow.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<router-link
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
:key="index"
|
||||
ref="linkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="nav-link button-animation"
|
||||
>
|
||||
<span>{{ link.label }}</span>
|
||||
</router-link>
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:style="{
|
||||
left: positionToMoveX,
|
||||
top: positionToMoveY,
|
||||
width: sliderWidth,
|
||||
opacity: activeIndex === -1 ? 0 : 1,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
links: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
query: {
|
||||
default: null,
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sliderPositionX: 0,
|
||||
sliderPositionY: 18,
|
||||
selectedElementWidth: 0,
|
||||
activeIndex: -1,
|
||||
oldIndex: -1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredLinks() {
|
||||
return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
|
||||
},
|
||||
positionToMoveX() {
|
||||
return `${this.sliderPositionX}px`
|
||||
},
|
||||
positionToMoveY() {
|
||||
return `${this.sliderPositionY}px`
|
||||
},
|
||||
sliderWidth() {
|
||||
return `${this.selectedElementWidth}px`
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'$route.path': {
|
||||
handler() {
|
||||
this.pickLink()
|
||||
},
|
||||
},
|
||||
'$route.query': {
|
||||
handler() {
|
||||
if (this.query) this.pickLink()
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.pickLink)
|
||||
this.pickLink()
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.pickLink)
|
||||
},
|
||||
methods: {
|
||||
pickLink() {
|
||||
this.activeIndex = this.query
|
||||
? this.filteredLinks.findIndex(
|
||||
(x) => (x.href === '' ? undefined : x.href) === this.$route.path[this.query]
|
||||
)
|
||||
: this.filteredLinks.findIndex((x) => x.href === decodeURIComponent(this.$route.path))
|
||||
|
||||
if (this.activeIndex !== -1) {
|
||||
this.startAnimation()
|
||||
} else {
|
||||
this.oldIndex = -1
|
||||
this.sliderPositionX = 0
|
||||
this.selectedElementWidth = 0
|
||||
}
|
||||
},
|
||||
startAnimation() {
|
||||
const el = this.$refs.linkElements[this.activeIndex].$el
|
||||
|
||||
this.sliderPositionX = el.offsetLeft
|
||||
this.sliderPositionY = el.offsetTop + el.offsetHeight
|
||||
this.selectedElementWidth = el.offsetWidth
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navigation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
|
||||
.nav-link {
|
||||
text-transform: capitalize;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-base);
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-base);
|
||||
|
||||
&::after {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--color-base);
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.use-animation {
|
||||
.nav-link {
|
||||
&.is-active::after {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
height: 0.25rem;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 3rem;
|
||||
transition: all ease-in-out 0.2s;
|
||||
border-radius: var(--radius-max);
|
||||
background-color: var(--color-brand);
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
packages/ui/src/components/nav/NavStack.vue
Normal file
22
packages/ui/src/components/nav/NavStack.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="omorphia__navstack">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omorphia__navstack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.btn) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-button-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
packages/ui/src/components/search/Categories.vue
Normal file
46
packages/ui/src/components/search/Categories.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="categories">
|
||||
<slot />
|
||||
<span
|
||||
v-for="category in categories"
|
||||
:key="category.name"
|
||||
v-html="category.icon + formatCategory(category.name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
:deep(span) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:not(.version-badge) {
|
||||
color: var(--color-gray);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
337
packages/ui/src/components/search/SearchDropdown.vue
Normal file
337
packages/ui/src/components/search/SearchDropdown.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div class="iconified-input">
|
||||
<SearchIcon />
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
class="text-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
:placeholder="placeholder"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@focusout="onBlur"
|
||||
@keydown.enter.prevent="$emit('enter')"
|
||||
/>
|
||||
<Button :disabled="disabled" class="r-btn" @click="() => $emit('update:modelValue', '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div ref="dropdownOptions" class="options-wrapper" :class="{ down: !renderUp, up: renderUp }">
|
||||
<transition name="options">
|
||||
<div
|
||||
v-show="dropdownVisible"
|
||||
class="options"
|
||||
role="listbox"
|
||||
:class="{ down: !renderUp, up: renderUp }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in options"
|
||||
:key="index"
|
||||
ref="optionElements"
|
||||
tabindex="-1"
|
||||
role="option"
|
||||
class="option"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<div class="project-label">
|
||||
<Avatar :src="option.icon" :circle="circledIcons" />
|
||||
<div class="text">
|
||||
<div class="title">
|
||||
{{ displayName(option.title) }}
|
||||
</div>
|
||||
<div class="author">
|
||||
{{ displayName(option.subtitle) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { XIcon, SearchIcon } from '@modrinth/assets'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import Button from '../base/Button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: null,
|
||||
},
|
||||
renderUp: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
displayName: {
|
||||
type: Function,
|
||||
default: (option) => option,
|
||||
},
|
||||
circledIcons: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['input', 'onSelected', 'update:modelValue', 'enter'])
|
||||
|
||||
const dropdownVisible = ref(false)
|
||||
const focusedOptionIndex = ref(null)
|
||||
const dropdown = ref(null)
|
||||
const optionElements = ref(null)
|
||||
const dropdownOptions = ref(null)
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
dropdownVisible.value = !dropdownVisible.value
|
||||
dropdown.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option) => {
|
||||
emit('onSelected', option)
|
||||
console.log('onSelected', option)
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex(
|
||||
(option) => option === props.modelValue.value
|
||||
)
|
||||
dropdownVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
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 === dropdownOptions.value) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animated-dropdown {
|
||||
width: 20rem;
|
||||
height: 2.5rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: var(--color-button-bg);
|
||||
gap: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
z-index: 10;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&: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);
|
||||
}
|
||||
}
|
||||
|
||||
.project-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent !important;
|
||||
width: 100%;
|
||||
|
||||
transition: 0.05s;
|
||||
|
||||
&:focus {
|
||||
&.down {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0 !important;
|
||||
}
|
||||
|
||||
&.up {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:focus) {
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
74
packages/ui/src/components/search/SearchFilter.vue
Normal file
74
packages/ui/src/components/search/SearchFilter.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Checkbox
|
||||
class="filter"
|
||||
:model-value="isActive"
|
||||
:description="displayName"
|
||||
@update:model-value="toggle"
|
||||
>
|
||||
<div class="filter-text">
|
||||
<div v-if="props.icon" aria-hidden="true" class="icon" v-html="props.icon" />
|
||||
<div v-else class="icon">
|
||||
<slot />
|
||||
</div>
|
||||
<span aria-hidden="true"> {{ props.displayName }}</span>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
facetName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
displayName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeFilters: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const isActive = computed(() => props.activeFilters.includes(props.facetName))
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const toggle = () => {
|
||||
emit('toggle', props.facetName)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.filter {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
:deep(.filter-text) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
|
||||
svg {
|
||||
margin-right: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
2
packages/ui/src/vite-env.d.ts
vendored
Normal file
2
packages/ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-svg-loader" />
|
||||
6
packages/ui/src/vue-shims.d.ts
vendored
Normal file
6
packages/ui/src/vue-shims.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
const component: ReturnType<typeof defineComponent>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user