forked from didirus/AstralRinth
Modal report (#17)
* Base modal implementation * Modal Report page * Upgrade multiselect * Fixed multiselect styling * fix build err * rev change * Added dropdown component, addressed changes Removed unused classes after vue multiselect was removed Updated markdown-it and xss * Update index.js * fix lint * Fix prettier code style * Address most changes * New dropdown * Undo comment Makes the component close when not focused * Fix accessibility issues * Fix double focus * addressed changes to modal * Run Prettier * Update ModalReport.vue * Fixed spacing issues --------- Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
@@ -26,6 +26,8 @@ export default {
|
||||
{ text: 'Drop Area', link: '/components/drop-area' },
|
||||
{ text: 'Icons', link: '/components/icons' },
|
||||
{ text: 'Pagination', link: '/components/pagination' },
|
||||
{ text: 'Modal', link: '/components/modal' },
|
||||
{ text: 'Dropdown Select', link: '/components/dropdown-select' },
|
||||
{ text: 'Project Card', link: '/components/project-card' },
|
||||
{ text: 'Environment Indicator', link: '/components/environment-indicator' },
|
||||
{ text: 'Categories', link: '/components/categories' },
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<div class="demo"><slot /></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.demo {
|
||||
background: var(--color-raised-bg);
|
||||
@@ -16,5 +12,8 @@ export default {}
|
||||
gap: var(--gap-md);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
background: var(--color-raised-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--gap-md);
|
||||
}
|
||||
</style>
|
||||
|
||||
19
docs/components/dropdown-select.md
Normal file
19
docs/components/dropdown-select.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Dropdown
|
||||
|
||||
<DemoContainer>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
:options="['Daily', 'Weekly', 'Monthly']"
|
||||
defaultValue="Choose Frequency"
|
||||
/>
|
||||
</DemoContainer>
|
||||
|
||||
```vue
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
:options="['Daily', 'Weekly', 'Monthly']"
|
||||
defaultValue="Choose Frequency"
|
||||
/>
|
||||
```
|
||||
22
docs/components/modal.md
Normal file
22
docs/components/modal.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Modal
|
||||
:::raw
|
||||
|
||||
<DemoContainer>
|
||||
<Button :action="() => this.$refs.reportModal.modal.show()">Show Modal</Button>
|
||||
<ModalReport
|
||||
ref="reportModal"
|
||||
itemType="project"
|
||||
:reportTypes="['cringitude', 'rudeness', 'notgamer', 'windowsuser']"
|
||||
>
|
||||
</ModalReport>
|
||||
</DemoContainer>
|
||||
:::
|
||||
|
||||
```vue
|
||||
<Button :action="() => this.$refs.reportModal.modal.show()">Show Modal</Button>
|
||||
<ModalReport
|
||||
ref="reportModal"
|
||||
itemType="project"
|
||||
:reportTypes="['cringitude', 'rudeness', 'notgamer', 'windowsuser']"
|
||||
/>
|
||||
```
|
||||
@@ -753,3 +753,18 @@ a,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
-moz-border-radius: 3px;
|
||||
-webkit-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
background: url(http://dropdown/arrow/url/) no-repeat;
|
||||
background-position: right center;
|
||||
background-color: #cccccc;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ a.uncolored {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
input[type='text'],
|
||||
textarea {
|
||||
border-radius: var(--radius-md);
|
||||
box-sizing: border-box;
|
||||
border: 2px solid transparent;
|
||||
@@ -157,3 +158,17 @@ button {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
&:disabled {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
.button-animation,
|
||||
button {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
235
lib/components/base/DropdownSelect.vue
Normal file
235
lib/components/base/DropdownSelect.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="animated-dropdown"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown.enter.prevent="toggleDropdown"
|
||||
@keydown.up.prevent="focusPreviousOption"
|
||||
@keydown.down.prevent="focusNextOptionOrOpen"
|
||||
>
|
||||
<div class="selected" :class="{ 'dropdown-open': dropdownVisible }" @click="toggleDropdown">
|
||||
<span>{{ selectedOption }}</span>
|
||||
<i class="arrow" :class="{ rotate: dropdownVisible }"></i>
|
||||
</div>
|
||||
<transition name="slide-fade">
|
||||
<div v-show="dropdownVisible" class="options" role="listbox">
|
||||
<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="selectedValue"
|
||||
type="radio"
|
||||
:value="option"
|
||||
:name="name"
|
||||
/>
|
||||
<label :for="`${name}-${index}`">{{ option }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
defaultValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['input', 'change'],
|
||||
data() {
|
||||
return {
|
||||
dropdownVisible: false,
|
||||
selectedValue: this.defaultValue,
|
||||
focusedOptionIndex: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedOption() {
|
||||
return this.selectedValue || this.placeholder || 'Select an option'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
this.dropdownVisible = !this.dropdownVisible
|
||||
},
|
||||
selectOption(option, index) {
|
||||
this.selectedValue = option
|
||||
this.$emit('input', this.selectedValue)
|
||||
this.$emit('change', { option, index })
|
||||
this.dropdownVisible = false
|
||||
},
|
||||
onFocus() {
|
||||
this.focusedOptionIndex = this.options.findIndex((option) => option === this.selectedValue)
|
||||
this.dropdownVisible = true
|
||||
},
|
||||
onBlur(event) {
|
||||
if (!this.isChildOfDropdown(event.relatedTarget)) {
|
||||
this.dropdownVisible = false
|
||||
}
|
||||
},
|
||||
focusPreviousOption() {
|
||||
if (!this.dropdownVisible) {
|
||||
this.toggleDropdown()
|
||||
}
|
||||
this.focusedOptionIndex =
|
||||
(this.focusedOptionIndex + this.options.length - 1) % this.options.length
|
||||
this.$refs.optionElements[this.focusedOptionIndex].focus()
|
||||
},
|
||||
focusNextOptionOrOpen() {
|
||||
if (!this.dropdownVisible) {
|
||||
this.toggleDropdown()
|
||||
}
|
||||
this.focusedOptionIndex = (this.focusedOptionIndex + 1) % this.options.length
|
||||
this.$refs.optionElements[this.focusedOptionIndex].focus()
|
||||
},
|
||||
isChildOfDropdown(element) {
|
||||
let currentNode = element
|
||||
while (currentNode) {
|
||||
if (currentNode === this.$el) {
|
||||
return true
|
||||
}
|
||||
currentNode = currentNode.parentNode
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.animated-dropdown {
|
||||
width: 20rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--gap-sm) var(--gap-md);
|
||||
background-color: var(--color-button-bg);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
&.dropdown-open {
|
||||
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 {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-left: 0.4rem;
|
||||
border-left: 0.4rem solid transparent;
|
||||
border-right: 0.4rem solid transparent;
|
||||
border-top: 0.4rem solid var(--color-base);
|
||||
transition: transform 0.3s ease;
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
overflow: hidden;
|
||||
|
||||
.option {
|
||||
background-color: var(--color-button-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--gap-md);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
filter: brightness(1.25);
|
||||
transition: filter 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
&.selected-option {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide-fade-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.slide-fade-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: opacity 0.5s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-fade-leave,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
</style>
|
||||
121
lib/components/base/Modal.vue
Normal file
121
lib/components/base/Modal.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:class="{
|
||||
shown: shown,
|
||||
noblur: noblur,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
@click="hide"
|
||||
/>
|
||||
<div class="modal-body" :class="{ shown: shown }">
|
||||
<div v-if="header" class="header">
|
||||
<h1>{{ header }}</h1>
|
||||
<button class="iconified-button icon-only transparent" @click="hide">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
noblur: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shown: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.shown = true
|
||||
},
|
||||
hide() {
|
||||
this.shown = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-overlay {
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
background: hsla(0, 0%, 0%, 0.5);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
&.noblur {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 21;
|
||||
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;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--gap-md) var(--gap-lg);
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-contrast);
|
||||
font-weight: bolder;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
top: calc(100% + 400px);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
width: calc(100% - 2 * var(--gap-lg));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
lib/components/base/ModalReport.vue
Normal file
149
lib/components/base/ModalReport.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<Modal ref="modal" :header="`Report ${itemType}`">
|
||||
<div class="modal-report">
|
||||
<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>
|
||||
<div>
|
||||
<label class="report-label" for="report-type">
|
||||
<span>
|
||||
<strong>Reason</strong>
|
||||
</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="report-type"
|
||||
v-model="reportType"
|
||||
:options="reportTypes"
|
||||
default-value="Choose report type"
|
||||
class="multiselect"
|
||||
/>
|
||||
</div>
|
||||
<label class="report-label" for="additional-information">
|
||||
<strong>Additional information</strong>
|
||||
<span> Include links and images if possible. Markdown formatting is supported. </span>
|
||||
</label>
|
||||
<div>
|
||||
<div v-if="bodyViewType === 'source'" class="text-input textarea-wrapper">
|
||||
<Chips v-model="bodyViewType" class="separator" :items="['source', 'preview']" />
|
||||
<textarea id="body" v-model="body" spellcheck="true" />
|
||||
</div>
|
||||
<div v-else class="preview" v-html="renderString(body)"></div>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<Button @click="cancel">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" @click="submitReport">
|
||||
<CheckIcon />
|
||||
Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Modal, Chips, XIcon, CheckIcon, DropdownSelect } from '@/components'
|
||||
import { renderString } from '@/components/parse.js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const modal = ref('modal')
|
||||
defineExpose({
|
||||
modal: modal,
|
||||
})
|
||||
</script>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
itemType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
itemId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
reportTypes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
submitReport: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
reportType: '',
|
||||
body: '',
|
||||
bodyViewType: 'source',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
renderString,
|
||||
cancel() {
|
||||
this.reportType = ''
|
||||
this.body = ''
|
||||
this.bodyViewType = 'source'
|
||||
|
||||
this.$refs.modal.hide()
|
||||
},
|
||||
show() {
|
||||
this.$refs.modal.show()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-report {
|
||||
padding: var(--gap-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
grid-gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
height: 12rem;
|
||||
gap: 1rem;
|
||||
|
||||
textarea {
|
||||
// here due to a bug in safari
|
||||
max-height: 9rem;
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,11 @@ export { default as Slider } from './base/Slider.vue'
|
||||
export { default as AnimatedLogo } from './brand/AnimatedLogo.vue'
|
||||
export { default as TextLogo } from './brand/TextLogo.vue'
|
||||
export { default as Pagination } from './base/Pagination.vue'
|
||||
export { default as Modal } from './base/Modal.vue'
|
||||
export { default as ModalReport } from './base/ModalReport.vue'
|
||||
export { default as ProjectCard } from './base/ProjectCard.vue'
|
||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
|
||||
export { default as DropdownSelect } from './base/DropdownSelect.vue'
|
||||
export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as DropArea } from './base/DropArea.vue'
|
||||
|
||||
|
||||
136
lib/components/parse.js
Normal file
136
lib/components/parse.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import xss from 'xss'
|
||||
|
||||
export const configuredXss = new xss.FilterXSS({
|
||||
whiteList: {
|
||||
...xss.whiteList,
|
||||
summary: [],
|
||||
h1: ['id'],
|
||||
h2: ['id'],
|
||||
h3: ['id'],
|
||||
h4: ['id'],
|
||||
h5: ['id'],
|
||||
h6: ['id'],
|
||||
kbd: ['id'],
|
||||
input: ['checked', 'disabled', 'type'],
|
||||
iframe: ['width', 'height', 'allowfullscreen', 'frameborder', 'start', 'end'],
|
||||
img: [...xss.whiteList.img, 'style'],
|
||||
a: [...xss.whiteList.a, 'rel'],
|
||||
},
|
||||
css: {
|
||||
whiteList: {
|
||||
'image-rendering': /^pixelated$/,
|
||||
},
|
||||
},
|
||||
onIgnoreTagAttr: (tag, name, value) => {
|
||||
// Allow iframes from acceptable sources
|
||||
if (tag === 'iframe' && name === 'src') {
|
||||
const allowedSources = [
|
||||
{
|
||||
regex:
|
||||
/^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}(\?&autoplay=[0-1]{1})?$/,
|
||||
remove: ['&autoplay=1'], // Prevents autoplay
|
||||
},
|
||||
]
|
||||
|
||||
for (const source of allowedSources) {
|
||||
if (source.regex.test(value)) {
|
||||
for (const remove of source.remove) {
|
||||
value = value.replace(remove, '')
|
||||
}
|
||||
return name + '="' + xss.escapeAttrValue(value) + '"'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Highlight.JS
|
||||
if (
|
||||
name === 'class' &&
|
||||
['pre', 'code', 'span'].includes(tag) &&
|
||||
(value.startsWith('hljs-') || value.startsWith('language-'))
|
||||
) {
|
||||
return name + '="' + xss.escapeAttrValue(value) + '"'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const md = (options = {}) => {
|
||||
const md = new MarkdownIt('default', {
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
...options,
|
||||
})
|
||||
|
||||
const defaultLinkOpenRenderer =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx]
|
||||
const index = token.attrIndex('href')
|
||||
|
||||
if (index !== -1) {
|
||||
const href = token.attrs[index][1]
|
||||
|
||||
try {
|
||||
const url = new URL(href)
|
||||
const allowedHostnames = ['modrinth.com']
|
||||
|
||||
if (allowedHostnames.includes(url.hostname)) {
|
||||
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
tokens[idx].attrSet('rel', 'noopener nofollow ugc')
|
||||
|
||||
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
const defaultImageRenderer =
|
||||
md.renderer.rules.image ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.image = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx]
|
||||
const index = token.attrIndex('src')
|
||||
|
||||
if (index !== -1) {
|
||||
const src = token.attrs[index][1]
|
||||
|
||||
try {
|
||||
const url = new URL(src)
|
||||
|
||||
const allowedHostnames = [
|
||||
'i.imgur.com',
|
||||
'cdn-raw.modrinth.com',
|
||||
'cdn.modrinth.com',
|
||||
'staging-cdn-raw.modrinth.com',
|
||||
'staging-cdn.modrinth.com',
|
||||
'raw.githubusercontent.com',
|
||||
'img.shields.io',
|
||||
]
|
||||
|
||||
if (allowedHostnames.includes(url.hostname)) {
|
||||
return defaultImageRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
} catch (err) {
|
||||
/* empty */
|
||||
}
|
||||
token.attrs[index][1] = `//wsrv.nl/?url=${encodeURIComponent(src)}`
|
||||
}
|
||||
|
||||
return defaultImageRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
return md
|
||||
}
|
||||
|
||||
export const renderString = (string) => configuredXss.process(md().render(string))
|
||||
@@ -4,8 +4,8 @@ import FloatingVue from 'floating-vue'
|
||||
function install(app) {
|
||||
for (const key in components) {
|
||||
app.component(key, components[key])
|
||||
app.use(FloatingVue)
|
||||
}
|
||||
app.use(FloatingVue)
|
||||
}
|
||||
|
||||
export default { install }
|
||||
|
||||
@@ -26,8 +26,11 @@
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.7",
|
||||
"floating-vue": "^2.0.0-beta.20",
|
||||
"markdown-it": "^13.0.1",
|
||||
"vue": "^3.2.45",
|
||||
"vue-router": "^4.1.6"
|
||||
"vue-router": "^4.1.6",
|
||||
"vue-select": "^4.0.0-beta.6",
|
||||
"xss": "^1.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
|
||||
Reference in New Issue
Block a user