You've already forked AstralRinth
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: 'Drop Area', link: '/components/drop-area' },
|
||||||
{ text: 'Icons', link: '/components/icons' },
|
{ text: 'Icons', link: '/components/icons' },
|
||||||
{ text: 'Pagination', link: '/components/pagination' },
|
{ 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: 'Project Card', link: '/components/project-card' },
|
||||||
{ text: 'Environment Indicator', link: '/components/environment-indicator' },
|
{ text: 'Environment Indicator', link: '/components/environment-indicator' },
|
||||||
{ text: 'Categories', link: '/components/categories' },
|
{ text: 'Categories', link: '/components/categories' },
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
<div class="demo"><slot /></div>
|
<div class="demo"><slot /></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.demo {
|
.demo {
|
||||||
background: var(--color-raised-bg);
|
background: var(--color-raised-bg);
|
||||||
@@ -16,5 +12,8 @@ export default {}
|
|||||||
gap: var(--gap-md);
|
gap: var(--gap-md);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
background: var(--color-raised-bg);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--gap-md);
|
||||||
}
|
}
|
||||||
</style>
|
</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;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text'] {
|
input[type='text'],
|
||||||
|
textarea {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
@@ -157,3 +158,17 @@ button {
|
|||||||
transform: none !important;
|
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 AnimatedLogo } from './brand/AnimatedLogo.vue'
|
||||||
export { default as TextLogo } from './brand/TextLogo.vue'
|
export { default as TextLogo } from './brand/TextLogo.vue'
|
||||||
export { default as Pagination } from './base/Pagination.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 ProjectCard } from './base/ProjectCard.vue'
|
||||||
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.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 FileInput } from './base/FileInput.vue'
|
||||||
export { default as DropArea } from './base/DropArea.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) {
|
function install(app) {
|
||||||
for (const key in components) {
|
for (const key in components) {
|
||||||
app.component(key, components[key])
|
app.component(key, components[key])
|
||||||
app.use(FloatingVue)
|
|
||||||
}
|
}
|
||||||
|
app.use(FloatingVue)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { install }
|
export default { install }
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"floating-vue": "^2.0.0-beta.20",
|
"floating-vue": "^2.0.0-beta.20",
|
||||||
|
"markdown-it": "^13.0.1",
|
||||||
"vue": "^3.2.45",
|
"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": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.0.0",
|
"@vitejs/plugin-vue": "^4.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user