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:
Adrian O.V
2023-03-24 15:15:34 -04:00
committed by GitHub
parent d3d553ad5a
commit 4ae7786362
14 changed files with 2801 additions and 7 deletions

View 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>

View 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>

View 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, well 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>

View File

@@ -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
View 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))