1
0

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

@@ -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' },

View File

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

View 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
View 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']"
/>
```

View File

@@ -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;
}

View File

@@ -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;
}
}

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

View File

@@ -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 }

View File

@@ -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",

2075
yarn.lock Normal file

File diff suppressed because it is too large Load Diff