Add markdown enhancements
This commit will bring: - Linking to other users with `@username` - Linking to tags with `#tag` - Opening external links in new tab (Fix #1647) - Single line breaks to avoid confusion for non-technical users (Fix #1377) - 😒 support... - Email encoding in markdown - Markdown editor now auto-resizes to accomodate content (Fix #1379) NOTE: This only works in very few places. We need to wait for #1835 to have those features available widely
This commit is contained in:
parent
8aa073b976
commit
f06c040b50
|
@ -32,6 +32,8 @@ tasks:
|
|||
poetry run python manage.py gitpod dev
|
||||
|
||||
- name: Frontend
|
||||
env:
|
||||
VUE_EDITOR: code
|
||||
before: cd front
|
||||
init: |
|
||||
yarn install
|
||||
|
@ -42,6 +44,7 @@ tasks:
|
|||
env:
|
||||
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
|
||||
ENV_FILE: /workspace/funkwhale/.gitpod/.env
|
||||
VUE_EDITOR: code
|
||||
command: |
|
||||
clear
|
||||
echo ""
|
||||
|
|
|
@ -18,31 +18,35 @@
|
|||
"postinstall": "yarn run fix-fomantic-css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.191",
|
||||
"@tiptap/vue-3": "^2.0.0-beta.96",
|
||||
"@vue/runtime-core": "3.2.37",
|
||||
"@vueuse/core": "8.9.4",
|
||||
"@vueuse/integrations": "8.9.4",
|
||||
"axios": "0.27.2",
|
||||
"axios-auth-refresh": "3.3.1",
|
||||
"axios-auth-refresh": "3.3.3",
|
||||
"diff": "5.1.0",
|
||||
"dompurify": "2.3.8",
|
||||
"dompurify": "2.3.10",
|
||||
"focus-trap": "6.9.4",
|
||||
"fomantic-ui-css": "2.8.8",
|
||||
"howler": "2.2.3",
|
||||
"js-logger": "1.6.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"mavon-editor": "^3.0.0-beta",
|
||||
"moment": "2.29.4",
|
||||
"qs": "6.11.0",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sanitize-html": "2.7.1",
|
||||
"sass": "1.53.0",
|
||||
"sass": "1.54.0",
|
||||
"showdown": "2.1.0",
|
||||
"text-clipper": "2.2.0",
|
||||
"tiptap-markdown": "^0.5.0",
|
||||
"transliteration": "2.3.5",
|
||||
"vue": "3.2.37",
|
||||
"vue-gettext": "2.1.12",
|
||||
"vue-plyr": "7.0.0",
|
||||
"vue-router": "4.1.2",
|
||||
"vue-tsc": "0.38.9",
|
||||
"vue-tsc": "0.39.0",
|
||||
"vue-upload-component": "3.1.2",
|
||||
"vue-virtual-scroller": "^2.0.0-alpha.1",
|
||||
"vue3-gettext": "2.3.0",
|
||||
|
@ -66,7 +70,7 @@
|
|||
"@typescript-eslint/eslint-plugin": "5.30.7",
|
||||
"@vitejs/plugin-vue": "3.0.1",
|
||||
"@vue/compiler-sfc": "3.2.37",
|
||||
"@vue/eslint-config-standard": "7.0.0",
|
||||
"@vue/eslint-config-standard": "8.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.0",
|
||||
"@vue/test-utils": "2.0.2",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
|
@ -79,14 +83,15 @@
|
|||
"eslint-plugin-n": "15.2.4",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-promise": "6.0.0",
|
||||
"eslint-plugin-vue": "9.2.0",
|
||||
"eslint-plugin-vue": "9.3.0",
|
||||
"jest-cli": "28.1.3",
|
||||
"moxios": "0.4.0",
|
||||
"sinon": "14.0.0",
|
||||
"ts-jest": "28.0.7",
|
||||
"typescript": "4.7.4",
|
||||
"vite": "3.0.2",
|
||||
"vite": "3.0.3",
|
||||
"vite-plugin-pwa": "0.12.3",
|
||||
"vite-plugin-vue-inspector": "1.0.1",
|
||||
"vue-jest": "3.0.7",
|
||||
"workbox-core": "6.5.3",
|
||||
"workbox-precaching": "6.5.3",
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { useStore } from '~/store'
|
||||
import { get } from 'lodash-es'
|
||||
import showdown from 'showdown'
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import { humanSize } from '~/utils/filters'
|
||||
import { computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const markdown = new showdown.Converter()
|
||||
|
||||
const store = useStore()
|
||||
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
||||
|
||||
|
@ -18,9 +16,9 @@ const labels = computed(() => ({
|
|||
|
||||
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
|
||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||
const longDescription = computed(() => get(nodeinfo.value, 'metadata.longDescription'))
|
||||
const rules = computed(() => get(nodeinfo.value, 'metadata.rules'))
|
||||
const terms = computed(() => get(nodeinfo.value, 'metadata.terms'))
|
||||
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription'))
|
||||
const rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules'))
|
||||
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms'))
|
||||
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
||||
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
||||
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
|
||||
|
@ -140,7 +138,7 @@ const headerStyle = computed(() => {
|
|||
</h2>
|
||||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
:html="markdown.makeHtml(longDescription)"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<p v-else>
|
||||
<translate translate-context="Content/About/Paragraph">
|
||||
|
@ -158,7 +156,7 @@ const headerStyle = computed(() => {
|
|||
</h3>
|
||||
<sanitized-html
|
||||
v-if="rules"
|
||||
:html="markdown.makeHtml(rules)"
|
||||
:html="rules"
|
||||
/>
|
||||
<p v-else>
|
||||
<translate translate-context="Content/About/Paragraph">
|
||||
|
@ -176,7 +174,7 @@ const headerStyle = computed(() => {
|
|||
</h3>
|
||||
<sanitized-html
|
||||
v-if="terms"
|
||||
:html="markdown.makeHtml(terms)"
|
||||
:html="terms"
|
||||
/>
|
||||
<p v-else>
|
||||
<translate translate-context="Content/About/Paragraph">
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { get } from 'lodash-es'
|
||||
import showdown from 'showdown'
|
||||
import AlbumWidget from '~/components/audio/album/Widget.vue'
|
||||
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
||||
import LoginForm from '~/components/auth/LoginForm.vue'
|
||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import { humanSize } from '~/utils/filters'
|
||||
import { useStore } from '~/store'
|
||||
import { computed } from 'vue'
|
||||
|
@ -12,8 +12,6 @@ import { whenever } from '@vueuse/core'
|
|||
import { useGettext } from 'vue3-gettext'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const markdown = new showdown.Converter()
|
||||
|
||||
const { $pgettext } = useGettext()
|
||||
const labels = computed(() => ({
|
||||
title: $pgettext('Head/Home/Title', 'Home')
|
||||
|
@ -25,7 +23,7 @@ const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
|||
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
|
||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
||||
const longDescription = computed(() => get(nodeinfo.value, 'metadata.longDescription'))
|
||||
const longDescription = useMarkdown(() => get(nodeinfo.value, 'metadata.longDescription'))
|
||||
const rules = computed(() => get(nodeinfo.value, 'metadata.rules'))
|
||||
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
||||
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
||||
|
@ -111,7 +109,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
|||
<sanitized-html
|
||||
v-if="longDescription"
|
||||
id="renderedDescription"
|
||||
:html="markdown.makeHtml(longDescription)"
|
||||
:html="longDescription"
|
||||
/>
|
||||
<div
|
||||
v-if="longDescription"
|
||||
|
|
|
@ -11,6 +11,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
tag: 'div'
|
||||
})
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
// set all elements owning target to target=_blank
|
||||
if ('target' in node) {
|
||||
node.setAttribute('target', '_blank')
|
||||
}
|
||||
})
|
||||
|
||||
const html = computed(() => DOMPurify.sanitize(props.html))
|
||||
const root = () => h(props.tag, { innerHTML: html.value })
|
||||
</script>
|
||||
|
|
|
@ -46,7 +46,7 @@ const fetchData = async (url = props.url) => {
|
|||
count.value = response.data.count
|
||||
|
||||
const newObjects = !props.isActivity
|
||||
? response.data.results.map((track: Track) => { track })
|
||||
? response.data.results.map((track: Track) => ({ track }))
|
||||
: response.data.results
|
||||
|
||||
objects.push(...newObjects)
|
||||
|
@ -62,7 +62,7 @@ fetchData()
|
|||
const emit = defineEmits(['count'])
|
||||
watch(count, (to) => emit('count', to))
|
||||
|
||||
if (props.websocketHandlers.includes('Listen')) {
|
||||
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||
useWebSocketHandler('Listen', (event) => {
|
||||
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
|
||||
// count.value += 1
|
||||
|
@ -70,7 +70,7 @@ if (props.websocketHandlers.includes('Listen')) {
|
|||
// objects.unshift(event as Listening)
|
||||
// objects.pop()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -1,3 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import type { Library, Plugin, BackendError } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import { clone } from 'lodash-es'
|
||||
import useMarkdown, { useMarkdownRaw } from '~/composables/useMarkdown'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
plugin: Plugin
|
||||
libraries: Library[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const description = useMarkdown(() => props.plugin.description ?? '')
|
||||
const enabled = ref(props.plugin.enabled)
|
||||
const values = clone(props.plugin.values ?? {})
|
||||
|
||||
const errors = ref([] as string[])
|
||||
const isLoading = ref(false)
|
||||
const submit = async () => {
|
||||
isLoading.value = true
|
||||
errors.value = []
|
||||
|
||||
try {
|
||||
await axios.post(`plugins/${props.plugin.name}/${enabled.value ? 'enable' : 'disable'}`)
|
||||
await axios.post(`plugins/${props.plugin.name}`, values)
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const scan = async () => {
|
||||
isLoading.value = true
|
||||
errors.value = []
|
||||
|
||||
try {
|
||||
await axios.post(`plugins/${props.plugin.name}/scan`, values)
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const submitAndScan = async () => {
|
||||
await submit()
|
||||
await scan()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
:class="['ui segment form', {loading: isLoading}]"
|
||||
|
@ -6,7 +60,7 @@
|
|||
<h3>{{ plugin.label }}</h3>
|
||||
<sanitized-html
|
||||
v-if="plugin.description"
|
||||
:html="markdown.makeHtml(plugin.description)"
|
||||
:html="description"
|
||||
/>
|
||||
<template v-if="plugin.homepage">
|
||||
<div class="ui small hidden divider" />
|
||||
|
@ -74,8 +128,8 @@
|
|||
</div>
|
||||
<template v-if="plugin.conf?.length > 0">
|
||||
<template
|
||||
v-for="(field, key) in plugin.conf"
|
||||
:key="key"
|
||||
v-for="field in plugin.conf"
|
||||
:key="field.name"
|
||||
>
|
||||
<div
|
||||
v-if="field.type === 'text'"
|
||||
|
@ -89,7 +143,7 @@
|
|||
>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="markdown.makeHtml(field.help)"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
@ -105,7 +159,7 @@
|
|||
/>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="markdown.makeHtml(field.help)"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
@ -120,7 +174,7 @@
|
|||
>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="markdown.makeHtml(field.help)"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
@ -135,7 +189,7 @@
|
|||
>
|
||||
<sanitized-html
|
||||
v-if="field.help"
|
||||
:html="markdown.makeHtml(field.help)"
|
||||
:html="useMarkdownRaw(field.help)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -150,7 +204,6 @@
|
|||
</button>
|
||||
<button
|
||||
v-if="plugin.source"
|
||||
type="scan"
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
@click.prevent="submitAndScan"
|
||||
>
|
||||
|
@ -161,54 +214,3 @@
|
|||
<div class="ui clearing hidden divider" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { clone } from 'lodash-es'
|
||||
import showdown from 'showdown'
|
||||
export default {
|
||||
props: {
|
||||
plugin: { type: Object, required: true },
|
||||
libraries: { type: Array, required: true }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
markdown: new showdown.Converter(),
|
||||
isLoading: false,
|
||||
enabled: this.plugin.enabled,
|
||||
values: clone(this.plugin.values || {}),
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit () {
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
const url = `plugins/${this.plugin.name}`
|
||||
const enableUrl = this.enabled ? `${url}/enable` : `${url}/disable`
|
||||
await axios.post(enableUrl)
|
||||
try {
|
||||
await axios.post(url, this.values)
|
||||
} catch (e) {
|
||||
this.errors = e.backendErrors
|
||||
}
|
||||
this.isLoading = false
|
||||
},
|
||||
async scan () {
|
||||
this.isLoading = true
|
||||
this.errors = []
|
||||
const url = `plugins/${this.plugin.name}/scan`
|
||||
try {
|
||||
await axios.post(url, this.values)
|
||||
} catch (e) {
|
||||
this.errors = e.backendErrors
|
||||
}
|
||||
this.isLoading = false
|
||||
},
|
||||
async submitAndScan () {
|
||||
await this.submit()
|
||||
await this.scan()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,3 +1,82 @@
|
|||
<script setup lang="ts">
|
||||
import axios from 'axios'
|
||||
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core'
|
||||
import { ref, computed, watchEffect, onMounted, nextTick } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
autofocus?: boolean
|
||||
permissive?: boolean
|
||||
required?: boolean
|
||||
charLimit?: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: undefined,
|
||||
autofocus: false,
|
||||
charLimit: 5000,
|
||||
permissive: false,
|
||||
required: false
|
||||
})
|
||||
|
||||
const { $pgettext } = useGettext()
|
||||
const { textarea, input } = useTextareaAutosize()
|
||||
const value = useVModel(props, 'modelValue', emit)
|
||||
syncRef(value, input)
|
||||
|
||||
const isPreviewing = ref(false)
|
||||
const preview = ref()
|
||||
const isLoadingPreview = ref(false)
|
||||
|
||||
const labels = computed(() => ({
|
||||
placeholder: props.placeholder ?? $pgettext('*/Form/Placeholder', 'Write a few words here…')
|
||||
}))
|
||||
|
||||
const remainingChars = computed(() => props.charLimit - props.modelValue.length)
|
||||
|
||||
const loadPreview = async () => {
|
||||
isLoadingPreview.value = true
|
||||
try {
|
||||
const response = await axios.post('text-preview/', { text: value.value, permissive: props.permissive })
|
||||
preview.value = response.data.rendered
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
isLoadingPreview.value = false
|
||||
}
|
||||
|
||||
watchDebounced(value, async () => {
|
||||
await loadPreview()
|
||||
}, { immediate: true, debounce: 500 })
|
||||
|
||||
watchEffect(async () => {
|
||||
if (isPreviewing.value) {
|
||||
if (value.value && !preview.value && !isLoadingPreview.value) {
|
||||
await loadPreview()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
textarea.value.focus()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.autofocus) {
|
||||
await nextTick()
|
||||
textarea.value.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content-form ui segments">
|
||||
<div class="ui segment">
|
||||
|
@ -31,7 +110,7 @@
|
|||
<div class="line" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="preview === null">
|
||||
<p v-else-if="!preview">
|
||||
<translate translate-context="*/Form/Paragraph">
|
||||
Nothing to preview.
|
||||
</translate>
|
||||
|
@ -44,13 +123,10 @@
|
|||
<template v-else>
|
||||
<div class="ui transparent input">
|
||||
<textarea
|
||||
:id="fieldId"
|
||||
ref="textarea"
|
||||
v-model="newValue"
|
||||
:name="fieldId"
|
||||
:rows="rows"
|
||||
:required="required || null"
|
||||
:placeholder="placeholder || labels.placeholder"
|
||||
v-model="value"
|
||||
:required="required"
|
||||
:placeholder="labels.placeholder"
|
||||
/>
|
||||
</div>
|
||||
<div class="ui very small hidden divider" />
|
||||
|
@ -71,82 +147,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
fieldId: { type: String, default: 'change-content' },
|
||||
placeholder: { type: String, default: null },
|
||||
autofocus: { type: Boolean, default: false },
|
||||
charLimit: { type: Number, default: 5000, required: false },
|
||||
rows: { type: Number, default: 5, required: false },
|
||||
permissive: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isPreviewing: false,
|
||||
preview: null,
|
||||
newValue: this.modelValue,
|
||||
isLoadingPreview: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
placeholder: this.$pgettext('*/Form/Placeholder', 'Write a few words here…')
|
||||
}
|
||||
},
|
||||
remainingChars () {
|
||||
return this.charLimit - (this.modelValue || '').length
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
newValue (v) {
|
||||
this.preview = null
|
||||
this.$emit('update:modelValue', v)
|
||||
},
|
||||
modelValue: {
|
||||
async handler (v) {
|
||||
this.preview = null
|
||||
this.newValue = v
|
||||
if (this.isPreviewing) {
|
||||
await this.loadPreview()
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
async isPreviewing (v) {
|
||||
if (v && !!this.modelValue && this.preview === null && !this.isLoadingPreview) {
|
||||
await this.loadPreview()
|
||||
}
|
||||
if (!v) {
|
||||
await this.$nextTick()
|
||||
this.$refs.textarea.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textarea.focus()
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadPreview () {
|
||||
this.isLoadingPreview = true
|
||||
try {
|
||||
const response = await axios.post('text-preview/', { text: this.newValue, permissive: this.permissive })
|
||||
this.preview = response.data.rendered
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
this.isLoadingPreview = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,3 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import type { InstancePolicy } from '~/types'
|
||||
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
|
||||
interface Props {
|
||||
object: InstancePolicy
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const summary = useMarkdown(() => props.object.summary)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
|
@ -64,10 +78,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="markdown && object.summary">
|
||||
<div v-if="summary">
|
||||
<div class="ui hidden divider" />
|
||||
<p><strong><translate translate-context="Content/Moderation/*/Noun">Reason</translate></strong></p>
|
||||
<sanitized-html :html="markdown.makeHtml(object.summary)" />
|
||||
<sanitized-html :html="summary" />
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<button
|
||||
|
@ -81,21 +95,3 @@
|
|||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import showdown from 'showdown'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
object: { type: Object, default: null }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
markdown: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.markdown = showdown.Converter({ simplifiedAutoLink: true, openLinksInNewWindow: true })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,3 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import type { Note, BackendError } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
interface Emits {
|
||||
(e: 'created', note: Note): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
target: Note
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { $pgettext } = useGettext()
|
||||
const labels = computed(() => ({
|
||||
summaryPlaceholder: $pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…')
|
||||
}))
|
||||
|
||||
const summary = ref('')
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errors = ref([] as string[])
|
||||
const submit = async () => {
|
||||
isLoading.value = true
|
||||
errors.value = []
|
||||
|
||||
try {
|
||||
const response = await axios.post('manage/moderation/notes/', {
|
||||
target: props.target,
|
||||
summary: summary.value
|
||||
})
|
||||
|
||||
emit('created', response.data)
|
||||
summary.value = ''
|
||||
} catch (error) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="ui form"
|
||||
|
@ -34,7 +81,7 @@
|
|||
<button
|
||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||
type="submit"
|
||||
:disabled="isLoading || null"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<translate translate-context="Content/Moderation/Button.Label/Verb">
|
||||
Add note
|
||||
|
@ -42,48 +89,3 @@
|
|||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import showdown from 'showdown'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
target: { type: Object, required: true }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
markdown: new showdown.Converter(),
|
||||
isLoading: false,
|
||||
summary: '',
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return {
|
||||
summaryPlaceholder: this.$pgettext('Content/Moderation/Placeholder', 'Describe what actions have been taken, or any other related updates…')
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
const self = this
|
||||
this.isLoading = true
|
||||
const payload = {
|
||||
target: this.target,
|
||||
summary: this.summary
|
||||
}
|
||||
this.errors = []
|
||||
axios.post('manage/moderation/notes/', payload).then((response) => {
|
||||
self.$emit('created', response.data)
|
||||
self.summary = ''
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import type { Note } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import showdown from 'showdown'
|
||||
import { useMarkdownRaw } from '~/composables/useMarkdown'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
|
@ -11,8 +11,6 @@ interface Props {
|
|||
|
||||
defineProps<Props>()
|
||||
|
||||
const markdown = new showdown.Converter()
|
||||
|
||||
const emit = defineEmits(['deleted'])
|
||||
const isLoading = ref(false)
|
||||
const remove = async (note: Note) => {
|
||||
|
@ -51,7 +49,7 @@ const remove = async (note: Note) => {
|
|||
</div>
|
||||
<div class="extra text">
|
||||
<expandable-div :content="note.summary">
|
||||
<sanitized-html :html="markdown.makeHtml(note.summary)" />
|
||||
<sanitized-html :html="useMarkdownRaw(note.summary ?? '')" />
|
||||
</expandable-div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
|
|
|
@ -1,3 +1,138 @@
|
|||
<script setup lang="ts">
|
||||
import type { Report } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import useReportConfigs from '~/composables/moderation/useReportConfigs'
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
|
||||
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
|
||||
import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue'
|
||||
import InstancePolicyModal from '~/components/manage/moderation/InstancePolicyModal.vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'updated', updating: { type: string }): void
|
||||
(e: 'handled', isHandled: boolean): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initObj: Report
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const configs = useReportConfigs()
|
||||
|
||||
const obj = ref(props.initObj)
|
||||
const summary = useMarkdown(() => obj.value.summary ?? '')
|
||||
|
||||
const target = computed(() => obj.value.target
|
||||
? obj.value.target
|
||||
: obj.value.target_state._target
|
||||
)
|
||||
|
||||
const targetFields = computed(() => {
|
||||
if (!target.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const payload = obj.value.target_state
|
||||
const fields = configs[target.value.type].moderatedFields
|
||||
return fields.map((fieldConfig) => {
|
||||
const getValueRepr = fieldConfig.getValueRepr ?? (i => i)
|
||||
return {
|
||||
id: fieldConfig.id,
|
||||
label: fieldConfig.label,
|
||||
value: payload[fieldConfig.id],
|
||||
repr: getValueRepr(payload[fieldConfig.id]) ?? ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { $pgettext } = useGettext()
|
||||
const actions = computed(() => {
|
||||
if (!target.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
const typeConfig = configs[target.value.type]
|
||||
const deleteUrl = typeConfig.getDeleteUrl?.(target.value)
|
||||
return deleteUrl
|
||||
? [{
|
||||
label: $pgettext('Content/Moderation/Button/Verb', 'Delete reported object'),
|
||||
modalHeader: $pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'),
|
||||
modalContent: $pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'),
|
||||
modalConfirmLabel: $pgettext('*/*/*/Verb', 'Delete'),
|
||||
icon: 'x',
|
||||
iconColor: 'danger',
|
||||
show: (obj: Report) => { return !!obj.target },
|
||||
dangerous: true,
|
||||
handler: async () => {
|
||||
try {
|
||||
await axios.delete(deleteUrl)
|
||||
console.log('Target deleted')
|
||||
obj.value.target = undefined
|
||||
resolveReport(true)
|
||||
} catch (error) {
|
||||
console.log('Error while deleting target', error)
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
}
|
||||
}]
|
||||
: []
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const updating = reactive({ type: false })
|
||||
const update = async (type: string) => {
|
||||
isLoading.value = true
|
||||
updating.type = true
|
||||
|
||||
try {
|
||||
await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { type })
|
||||
emit('updated', { type })
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
|
||||
updating.type = false
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
const isCollapsed = ref(false)
|
||||
const resolveReport = async (isHandled: boolean) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await axios.patch(`manage/moderation/reports/${obj.value.uuid}/`, { is_handled: isHandled })
|
||||
emit('handled', isHandled)
|
||||
obj.value.is_handled = isHandled
|
||||
|
||||
if (isHandled) {
|
||||
isCollapsed.value = true
|
||||
}
|
||||
|
||||
store.commit('ui/incrementNotifications', {
|
||||
type: 'pendingReviewReports',
|
||||
count: isHandled ? -1 : 1
|
||||
})
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const handleRemovedNote = (uuid: string) => {
|
||||
obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui fluid report card">
|
||||
<div class="content">
|
||||
|
@ -48,7 +183,7 @@
|
|||
<td>
|
||||
<report-category-dropdown
|
||||
v-model="obj.type"
|
||||
@update:model-value="update({ type: $event })"
|
||||
@update:model-value="update($event)"
|
||||
>
|
||||
 
|
||||
<action-feedback :is-loading="updating.type" />
|
||||
|
@ -163,11 +298,11 @@
|
|||
</translate>
|
||||
</h3>
|
||||
<expandable-div
|
||||
v-if="obj.summary"
|
||||
v-if="summary"
|
||||
class="summary"
|
||||
:content="obj.summary"
|
||||
>
|
||||
<sanitized-html :html="markdown.makeHtml(obj.summary)" />
|
||||
<sanitized-html :html="summary" />
|
||||
</expandable-div>
|
||||
</div>
|
||||
<aside class="column">
|
||||
|
@ -342,7 +477,7 @@
|
|||
<button
|
||||
v-if="obj.is_handled === false"
|
||||
:class="['ui', {loading: isLoading}, 'button']"
|
||||
@click="resolve(true)"
|
||||
@click="resolveReport(true)"
|
||||
>
|
||||
<i class="success check icon" />
|
||||
<translate translate-context="Content/*/Button.Label/Verb">
|
||||
|
@ -352,7 +487,7 @@
|
|||
<button
|
||||
v-if="obj.is_handled === true"
|
||||
:class="['ui', {loading: isLoading}, 'button']"
|
||||
@click="resolve(false)"
|
||||
@click="resolveReport(false)"
|
||||
>
|
||||
<i class="warning redo icon" />
|
||||
<translate translate-context="Content/*/Button.Label">
|
||||
|
@ -389,174 +524,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
|
||||
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
|
||||
import ReportCategoryDropdown from '~/components/moderation/ReportCategoryDropdown.vue'
|
||||
import InstancePolicyModal from '~/components/manage/moderation/InstancePolicyModal.vue'
|
||||
import useReportConfigs from '~/composables/moderation/useReportConfigs.ts'
|
||||
import { setUpdate } from '~/utils'
|
||||
import showdown from 'showdown'
|
||||
|
||||
function castValue (value) {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NoteForm,
|
||||
NotesThread,
|
||||
ReportCategoryDropdown,
|
||||
InstancePolicyModal
|
||||
},
|
||||
props: {
|
||||
initObj: { type: Object, required: true },
|
||||
currentState: { type: String, required: false, default: '' }
|
||||
},
|
||||
setup () {
|
||||
return { configs: useReportConfigs() }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
obj: this.initObj,
|
||||
markdown: new showdown.Converter(),
|
||||
isLoading: false,
|
||||
isCollapsed: false,
|
||||
updating: {
|
||||
type: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
previousState () {
|
||||
if (this.obj.is_applied) {
|
||||
// mutation was applied, we use the previous state that is stored
|
||||
// on the mutation itself
|
||||
return this.obj.previous_state
|
||||
}
|
||||
// mutation is not applied yet, so we use the current state that was
|
||||
// passed to the component, if any
|
||||
return this.currentState
|
||||
},
|
||||
detailUrl () {
|
||||
if (!this.target) {
|
||||
return ''
|
||||
}
|
||||
let namespace
|
||||
const id = this.target.id
|
||||
if (this.target.type === 'track') {
|
||||
namespace = 'library.tracks.edit.detail'
|
||||
}
|
||||
if (this.target.type === 'album') {
|
||||
namespace = 'library.albums.edit.detail'
|
||||
}
|
||||
if (this.target.type === 'artist') {
|
||||
namespace = 'library.artists.edit.detail'
|
||||
}
|
||||
return this.$router.resolve({ name: namespace, params: { id, editId: this.obj.uuid } }).href
|
||||
},
|
||||
|
||||
targetFields () {
|
||||
if (!this.target) {
|
||||
return []
|
||||
}
|
||||
const payload = this.obj.target_state
|
||||
const fields = this.configs[this.target.type].moderatedFields
|
||||
return fields.map((fieldConfig) => {
|
||||
const getValueRepr = fieldConfig.getValueRepr ?? (i => i)
|
||||
return {
|
||||
id: fieldConfig.id,
|
||||
label: fieldConfig.label,
|
||||
value: payload[fieldConfig.id],
|
||||
repr: castValue(getValueRepr(payload[fieldConfig.id]))
|
||||
}
|
||||
})
|
||||
},
|
||||
target () {
|
||||
if (this.obj.target) {
|
||||
return this.obj.target
|
||||
} else {
|
||||
return this.obj.target_state._target
|
||||
}
|
||||
},
|
||||
actions () {
|
||||
if (!this.target) {
|
||||
return []
|
||||
}
|
||||
const self = this
|
||||
const actions = []
|
||||
const typeConfig = this.configs[this.target.type]
|
||||
if (typeConfig.getDeleteUrl) {
|
||||
const deleteUrl = typeConfig.getDeleteUrl(this.target)
|
||||
actions.push({
|
||||
label: this.$pgettext('Content/Moderation/Button/Verb', 'Delete reported object'),
|
||||
modalHeader: this.$pgettext('Content/Moderation/Popup/Header', 'Delete reported object?'),
|
||||
modalContent: this.$pgettext('Content/Moderation/Popup,Paragraph', 'This will delete the object associated with this report and mark the report as resolved. The deletion is irreversible.'),
|
||||
modalConfirmLabel: this.$pgettext('*/*/*/Verb', 'Delete'),
|
||||
icon: 'x',
|
||||
iconColor: 'danger',
|
||||
show: (obj) => { return !!obj.target },
|
||||
dangerous: true,
|
||||
handler: () => {
|
||||
axios.delete(deleteUrl).then((response) => {
|
||||
console.log('Target deleted')
|
||||
self.obj.target = null
|
||||
self.resolve(true)
|
||||
}, () => {
|
||||
console.log('Error while deleting target')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update (payload) {
|
||||
const url = `manage/moderation/reports/${this.obj.uuid}/`
|
||||
const self = this
|
||||
this.isLoading = true
|
||||
setUpdate(payload, this.updating, true)
|
||||
axios.patch(url, payload).then((response) => {
|
||||
self.$emit('updated', payload)
|
||||
Object.assign(self.obj, payload)
|
||||
self.isLoading = false
|
||||
setUpdate(payload, self.updating, false)
|
||||
}, () => {
|
||||
self.isLoading = false
|
||||
setUpdate(payload, self.updating, false)
|
||||
})
|
||||
},
|
||||
resolve (v) {
|
||||
const url = `manage/moderation/reports/${this.obj.uuid}/`
|
||||
const self = this
|
||||
this.isLoading = true
|
||||
axios.patch(url, { is_handled: v }).then((response) => {
|
||||
self.$emit('handled', v)
|
||||
self.isLoading = false
|
||||
self.obj.is_handled = v
|
||||
let increment
|
||||
if (v) {
|
||||
self.isCollapsed = true
|
||||
increment = -1
|
||||
} else {
|
||||
increment = 1
|
||||
}
|
||||
self.$store.commit('ui/incrementNotifications', { count: increment, type: 'pendingReviewReports' })
|
||||
}, () => {
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
handleRemovedNote (uuid) {
|
||||
this.obj.notes = this.obj.notes.filter((note) => {
|
||||
return note.uuid !== uuid
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,3 +1,66 @@
|
|||
<script setup lang="ts">
|
||||
import type { UserRequest, UserRequestStatus } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import { ref } from 'vue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
|
||||
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
|
||||
|
||||
interface Emits {
|
||||
(e: 'handled', status: UserRequestStatus): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initObj: UserRequest
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const obj = ref(props.initObj)
|
||||
|
||||
const isCollapsed = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const approve = async (isApproved: boolean) => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const status = isApproved
|
||||
? 'approved'
|
||||
: 'refused'
|
||||
|
||||
await axios.patch(`manage/moderation/requests/${obj.value.uuid}/`, {
|
||||
status
|
||||
})
|
||||
|
||||
emit('handled', status)
|
||||
|
||||
if (isApproved) {
|
||||
isCollapsed.value = true
|
||||
}
|
||||
|
||||
store.commit('ui/incrementNotifications', {
|
||||
type: 'pendingReviewRequests',
|
||||
count: -1
|
||||
})
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const handleRemovedNote = (uuid: string) => {
|
||||
obj.value.notes = obj.value.notes.filter((note) => note.uuid !== uuid)
|
||||
}
|
||||
|
||||
const isArray = Array.isArray
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui fluid user-request card">
|
||||
<div class="content">
|
||||
|
@ -157,12 +220,12 @@
|
|||
<template v-if="obj.metadata">
|
||||
<div class="ui hidden divider" />
|
||||
<div
|
||||
v-for="k in Object.keys(obj.metadata)"
|
||||
:key="k"
|
||||
v-for="(value, key) in obj.metadata"
|
||||
:key="key"
|
||||
>
|
||||
<h4>{{ k }}</h4>
|
||||
<p v-if="obj.metadata[k] && obj.metadata[k].length">
|
||||
{{ obj.metadata[k] }}
|
||||
<h4>{{ key }}</h4>
|
||||
<p v-if="isArray(value)">
|
||||
{{ value }}
|
||||
</p>
|
||||
<translate
|
||||
v-else
|
||||
|
@ -222,52 +285,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import NoteForm from '~/components/manage/moderation/NoteForm.vue'
|
||||
import NotesThread from '~/components/manage/moderation/NotesThread.vue'
|
||||
import showdown from 'showdown'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NoteForm,
|
||||
NotesThread
|
||||
},
|
||||
props: {
|
||||
initObj: { type: Object, required: true }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
markdown: new showdown.Converter(),
|
||||
isLoading: false,
|
||||
isCollapsed: false,
|
||||
obj: this.initObj
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
approve (v) {
|
||||
const url = `manage/moderation/requests/${this.obj.uuid}/`
|
||||
const self = this
|
||||
const newStatus = v ? 'approved' : 'refused'
|
||||
this.isLoading = true
|
||||
axios.patch(url, { status: newStatus }).then((response) => {
|
||||
self.$emit('handled', newStatus)
|
||||
self.isLoading = false
|
||||
self.obj.status = newStatus
|
||||
if (v) {
|
||||
self.isCollapsed = true
|
||||
}
|
||||
self.$store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewRequests' })
|
||||
}, () => {
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
handleRemovedNote (uuid) {
|
||||
this.obj.notes = this.obj.notes.filter((note) => {
|
||||
return note.uuid !== uuid
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import type { EntityObjectType } from '~/types'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { gettext } from '~/init/locale'
|
||||
|
@ -19,7 +20,6 @@ export interface Entity {
|
|||
moderatedFields: ModeratedField[]
|
||||
}
|
||||
|
||||
export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel'
|
||||
type Configs = Record<EntityObjectType, Entity>
|
||||
|
||||
const { $pgettext } = gettext
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import type { MaybeComputedRef } from '@vueuse/core'
|
||||
|
||||
import { resolveUnref } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import showdown from 'showdown'
|
||||
|
||||
showdown.extension('openExternalInNewTab', {
|
||||
type: 'output',
|
||||
regex: /<a.+?href.+">/g,
|
||||
replace (text: string) {
|
||||
const matches = text.match(/href="(.+)">/) ?? []
|
||||
const url = matches[1] ?? './'
|
||||
|
||||
if ((!url.startsWith('http://') && !url.startsWith('https://')) || url.startsWith('mailto:')) {
|
||||
return text
|
||||
}
|
||||
|
||||
const { hostname } = new URL(url)
|
||||
return hostname !== location.hostname
|
||||
? text.replace(matches[0], `href="${url}" target="_blank" rel="noopener noreferrer">`)
|
||||
: text
|
||||
}
|
||||
})
|
||||
|
||||
showdown.extension('linkifyTags', {
|
||||
type: 'language',
|
||||
regex: /#[^\W]+/g,
|
||||
replace (text: string) {
|
||||
return `<a href="/library/tags/${text.slice(1)}">${text}</a>`
|
||||
}
|
||||
})
|
||||
|
||||
const markdown = new showdown.Converter({
|
||||
extensions: ['openExternalInNewTab', 'linkifyTags'],
|
||||
ghMentions: true,
|
||||
ghMentionsLink: '/@{u}',
|
||||
simplifiedAutoLink: true,
|
||||
openLinksInNewWindow: false,
|
||||
simpleLineBreaks: true,
|
||||
strikethrough: true,
|
||||
tables: true,
|
||||
tasklists: true,
|
||||
underline: true,
|
||||
noHeaderId: true,
|
||||
headerLevelStart: 3,
|
||||
literalMidWordUnderscores: true,
|
||||
excludeTrailingPunctuationFromURLs: true,
|
||||
encodeEmails: true,
|
||||
emoji: true
|
||||
})
|
||||
|
||||
export const useMarkdownRaw = (md: string) => markdown.makeHtml(md)
|
||||
export const useMarkdownComputed = (md: MaybeComputedRef<string>) => computed(() => useMarkdownRaw(resolveUnref(md)))
|
||||
|
||||
export default useMarkdownComputed
|
|
@ -1,16 +1,29 @@
|
|||
|
||||
.content-form {
|
||||
background: var(--input-background);
|
||||
|
||||
.segment {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.segment:first-child {
|
||||
min-height: 15em;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
textarea {
|
||||
height: 100%;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ui.secondary.menu {
|
||||
background: none;
|
||||
margin-top: -0.5em;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -242,6 +242,10 @@ export interface PendingReviewRequestsWSEvent {
|
|||
pending_count: number
|
||||
}
|
||||
|
||||
export interface InboxItemAddedWSEvent {
|
||||
item: Notification
|
||||
}
|
||||
|
||||
export interface ListenWsEventObject {
|
||||
local_id: string
|
||||
}
|
||||
|
@ -256,7 +260,7 @@ export interface ListenWSEvent {
|
|||
// type: 'Listen'
|
||||
// }
|
||||
|
||||
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent
|
||||
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent | InboxItemAddedWSEvent
|
||||
|
||||
// FS Browser
|
||||
export interface FSEntry {
|
||||
|
@ -374,7 +378,96 @@ export interface SettingsDataEntry {
|
|||
// Note stuff
|
||||
export interface Note {
|
||||
uuid: string
|
||||
author: Actor // TODO (wvffle): Check if is valid
|
||||
summary: string
|
||||
creation_date: string
|
||||
type: 'request' | 'report'
|
||||
author?: Actor // TODO (wvffle): Check if is valid
|
||||
summary?: string
|
||||
creation_date?: string
|
||||
}
|
||||
|
||||
// Instance policy stuff
|
||||
export interface InstancePolicy {
|
||||
id: number
|
||||
uuid: string
|
||||
creation_date: string
|
||||
actor: Actor
|
||||
|
||||
summary: string
|
||||
is_active: boolean
|
||||
block_all: boolean
|
||||
silence_activity: boolean
|
||||
silence_notifications: boolean
|
||||
reject_media: boolean
|
||||
}
|
||||
|
||||
// Plugin stuff
|
||||
export interface Plugin {
|
||||
name: string
|
||||
label: string
|
||||
homepage?: string
|
||||
enabled: boolean
|
||||
description?: string
|
||||
source?: string
|
||||
values?: Record<string, string>
|
||||
conf?: {
|
||||
name: string
|
||||
label: string
|
||||
type: 'text' | 'long_text' | 'url' | 'password'
|
||||
help?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// Report stuff
|
||||
export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel'
|
||||
|
||||
export interface ReportTarget {
|
||||
id: string
|
||||
type: EntityObjectType
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
uuid: string
|
||||
summary?: string
|
||||
is_applied: boolean
|
||||
is_handled: boolean
|
||||
previous_state: string
|
||||
notes: Note[]
|
||||
type: string
|
||||
|
||||
assigned_to?: Actor
|
||||
submitter?: Actor
|
||||
submitter_email?: string
|
||||
|
||||
target_owner?: Actor
|
||||
target?: ReportTarget
|
||||
target_state: {
|
||||
_target: ReportTarget
|
||||
domain: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
creation_date: string
|
||||
handled_date: string
|
||||
}
|
||||
|
||||
// User request stuff
|
||||
export type UserRequestStatus = 'approved' | 'refused' | 'pending'
|
||||
export interface UserRequest {
|
||||
uuid: string
|
||||
notes: Note[]
|
||||
status: UserRequestStatus
|
||||
|
||||
assigned_to?: Actor
|
||||
submitter?: Actor
|
||||
submitter_email?: string
|
||||
|
||||
creation_date: string
|
||||
handled_date: string
|
||||
|
||||
metadata: object
|
||||
}
|
||||
|
||||
// Notification stuff
|
||||
export interface Notification {
|
||||
id: number
|
||||
is_read: boolean
|
||||
}
|
||||
|
|
|
@ -4,12 +4,6 @@ import type { Router } from 'vue-router'
|
|||
import type { APIErrorResponse } from '~/types'
|
||||
import type { RootState } from '~/store'
|
||||
|
||||
export function setUpdate (obj: object, statuses: Record<string, unknown>, value: unknown) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
statuses[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAPIErrors (responseData: APIErrorResponse, parentField?: string): string[] {
|
||||
const errors = []
|
||||
for (const [field, value] of Object.entries(responseData)) {
|
||||
|
|
|
@ -1,3 +1,92 @@
|
|||
<script setup lang="ts">
|
||||
import type { Notification, InboxItemAddedWSEvent } from '~/types'
|
||||
|
||||
import axios from 'axios'
|
||||
import moment from 'moment'
|
||||
|
||||
import { ref, reactive, computed, watch, markRaw } from 'vue'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useStore } from '~/store'
|
||||
import useMarkdown from '~/composables/useMarkdown'
|
||||
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||
|
||||
import NotificationRow from '~/components/notifications/NotificationRow.vue'
|
||||
|
||||
const store = useStore()
|
||||
const supportMessage = useMarkdown(() => store.state.instance.settings.instance.support_message.value)
|
||||
const { $pgettext } = useGettext()
|
||||
|
||||
const additionalNotifications = computed(() => store.getters['ui/additionalNotifications'])
|
||||
const showInstanceSupportMessage = computed(() => store.getters['ui/showInstanceSupportMessage'])
|
||||
const showFunkwhaleSupportMessage = computed(() => store.getters['ui/showFunkwhaleSupportMessage'])
|
||||
|
||||
const labels = computed(() => ({
|
||||
title: $pgettext('*/Notifications/*', 'Notifications')
|
||||
}))
|
||||
|
||||
const filters = reactive({
|
||||
is_read: false
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const notifications = reactive({ count: 0, results: [] as Notification[] })
|
||||
const fetchData = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await axios.get('federation/inbox/', { params: filters })
|
||||
notifications.count = response.data.count
|
||||
notifications.results = response.data.results.map(markRaw)
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
watch(filters, fetchData, { immediate: true })
|
||||
|
||||
useWebSocketHandler('inbox.item_added', (event) => {
|
||||
notifications.count += 1
|
||||
notifications.results.unshift(markRaw((event as InboxItemAddedWSEvent).item))
|
||||
})
|
||||
|
||||
const instanceSupportMessageDelay = ref(60)
|
||||
const funkwhaleSupportMessageDelay = ref(60)
|
||||
|
||||
const setDisplayDate = async (field: string, days: number) => {
|
||||
try {
|
||||
const response = await axios.patch(`users/${store.state.auth.username}/`, {
|
||||
[field]: days
|
||||
? moment().add({ days })
|
||||
: undefined
|
||||
})
|
||||
|
||||
store.commit('auth/profilePartialUpdate', response.data)
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
}
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
try {
|
||||
await axios.post('federation/inbox/action/', {
|
||||
action: 'read',
|
||||
objects: 'all',
|
||||
filters: {
|
||||
is_read: false,
|
||||
before: notifications.results[0]?.id
|
||||
}
|
||||
})
|
||||
|
||||
store.commit('ui/notifications', { type: 'inbox', count: 0 })
|
||||
notifications.results = notifications.results.map(notification => ({ ...notification, is_read: true }))
|
||||
} catch (error) {
|
||||
// TODO (wvffle): Handle error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
|
@ -25,7 +114,7 @@
|
|||
Support this Funkwhale pod
|
||||
</translate>
|
||||
</h4>
|
||||
<sanitized-html :html="markdown.makeHtml($store.state.instance.settings.instance.support_message.value)" />
|
||||
<sanitized-html :html="supportMessage" />
|
||||
</div>
|
||||
<div class="ui bottom attached segment">
|
||||
<form
|
||||
|
@ -210,104 +299,3 @@
|
|||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import showdown from 'showdown'
|
||||
import moment from 'moment'
|
||||
|
||||
import NotificationRow from '~/components/notifications/NotificationRow.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotificationRow
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
markdown: new showdown.Converter(),
|
||||
notifications: { count: 0, results: [] },
|
||||
instanceSupportMessageDelay: 60,
|
||||
funkwhaleSupportMessageDelay: 60,
|
||||
filters: {
|
||||
is_read: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
additionalNotifications: 'ui/additionalNotifications',
|
||||
showInstanceSupportMessage: 'ui/showInstanceSupportMessage',
|
||||
showFunkwhaleSupportMessage: 'ui/showFunkwhaleSupportMessage'
|
||||
}),
|
||||
labels () {
|
||||
return {
|
||||
title: this.$pgettext('*/Notifications/*', 'Notifications')
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'filters.is_read' () {
|
||||
this.fetch(this.filters)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetch(this.filters)
|
||||
this.$store.commit('ui/addWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'notificationPage',
|
||||
handler: this.handleNewNotification
|
||||
})
|
||||
},
|
||||
unmounted () {
|
||||
this.$store.commit('ui/removeWebsocketEventHandler', {
|
||||
eventName: 'inbox.item_added',
|
||||
id: 'notificationPage'
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
handleNewNotification (event) {
|
||||
this.notifications.count += 1
|
||||
this.notifications.results.unshift(event.item)
|
||||
},
|
||||
setDisplayDate (field, days) {
|
||||
const payload = {}
|
||||
let newDisplayDate
|
||||
if (days) {
|
||||
newDisplayDate = moment().add({ days })
|
||||
} else {
|
||||
newDisplayDate = null
|
||||
}
|
||||
payload[field] = newDisplayDate
|
||||
axios.patch(`users/${this.$store.state.auth.username}/`, payload).then((response) => {
|
||||
this.$store.commit('auth/profilePartialUpdate', response.data)
|
||||
})
|
||||
},
|
||||
fetch (params) {
|
||||
this.isLoading = true
|
||||
axios.get('federation/inbox/', { params }).then(response => {
|
||||
this.isLoading = false
|
||||
this.notifications = response.data
|
||||
})
|
||||
},
|
||||
markAllAsRead () {
|
||||
const before = this.notifications.results[0].id
|
||||
const payload = {
|
||||
action: 'read',
|
||||
objects: 'all',
|
||||
filters: {
|
||||
is_read: false,
|
||||
before
|
||||
}
|
||||
}
|
||||
axios.post('federation/inbox/action/', payload).then(response => {
|
||||
this.$store.commit('ui/notifications', { type: 'inbox', count: 0 })
|
||||
this.notifications.results.forEach(n => {
|
||||
n.is_read = true
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
<template v-if="object">
|
||||
<div class="ui vertical stripe segment">
|
||||
<report-card :obj="object" />
|
||||
<report-card :init-obj="object" />
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// import type { HmrOptions } from 'vite'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Inspector from 'vite-plugin-vue-inspector'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { resolve } from 'path'
|
||||
|
||||
|
@ -22,16 +21,21 @@ export default defineConfig(() => ({
|
|||
}
|
||||
}),
|
||||
|
||||
// https://github.com/webfansplz/vite-plugin-vue-inspector
|
||||
Inspector({
|
||||
toggleComboKey: 'alt-shift-d'
|
||||
}),
|
||||
|
||||
// https://github.com/antfu/vite-plugin-pwa
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'serviceWorker.ts',
|
||||
manifestFilename: 'manifest.json',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
navigateFallback: 'index.html',
|
||||
webManifestUrl: '/front/manifest.json'
|
||||
navigateFallback: 'index.html'
|
||||
}
|
||||
})
|
||||
],
|
||||
|
|
900
front/yarn.lock
900
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue