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
|
poetry run python manage.py gitpod dev
|
||||||
|
|
||||||
- name: Frontend
|
- name: Frontend
|
||||||
|
env:
|
||||||
|
VUE_EDITOR: code
|
||||||
before: cd front
|
before: cd front
|
||||||
init: |
|
init: |
|
||||||
yarn install
|
yarn install
|
||||||
|
@ -42,6 +44,7 @@ tasks:
|
||||||
env:
|
env:
|
||||||
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
|
COMPOSE_FILE: /workspace/funkwhale/.gitpod/docker-compose.yml
|
||||||
ENV_FILE: /workspace/funkwhale/.gitpod/.env
|
ENV_FILE: /workspace/funkwhale/.gitpod/.env
|
||||||
|
VUE_EDITOR: code
|
||||||
command: |
|
command: |
|
||||||
clear
|
clear
|
||||||
echo ""
|
echo ""
|
||||||
|
|
|
@ -18,31 +18,35 @@
|
||||||
"postinstall": "yarn run fix-fomantic-css"
|
"postinstall": "yarn run fix-fomantic-css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/starter-kit": "^2.0.0-beta.191",
|
||||||
|
"@tiptap/vue-3": "^2.0.0-beta.96",
|
||||||
"@vue/runtime-core": "3.2.37",
|
"@vue/runtime-core": "3.2.37",
|
||||||
"@vueuse/core": "8.9.4",
|
"@vueuse/core": "8.9.4",
|
||||||
"@vueuse/integrations": "8.9.4",
|
"@vueuse/integrations": "8.9.4",
|
||||||
"axios": "0.27.2",
|
"axios": "0.27.2",
|
||||||
"axios-auth-refresh": "3.3.1",
|
"axios-auth-refresh": "3.3.3",
|
||||||
"diff": "5.1.0",
|
"diff": "5.1.0",
|
||||||
"dompurify": "2.3.8",
|
"dompurify": "2.3.10",
|
||||||
"focus-trap": "6.9.4",
|
"focus-trap": "6.9.4",
|
||||||
"fomantic-ui-css": "2.8.8",
|
"fomantic-ui-css": "2.8.8",
|
||||||
"howler": "2.2.3",
|
"howler": "2.2.3",
|
||||||
"js-logger": "1.6.1",
|
"js-logger": "1.6.1",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
|
"mavon-editor": "^3.0.0-beta",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"qs": "6.11.0",
|
"qs": "6.11.0",
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"sanitize-html": "2.7.1",
|
"sanitize-html": "2.7.1",
|
||||||
"sass": "1.53.0",
|
"sass": "1.54.0",
|
||||||
"showdown": "2.1.0",
|
"showdown": "2.1.0",
|
||||||
"text-clipper": "2.2.0",
|
"text-clipper": "2.2.0",
|
||||||
|
"tiptap-markdown": "^0.5.0",
|
||||||
"transliteration": "2.3.5",
|
"transliteration": "2.3.5",
|
||||||
"vue": "3.2.37",
|
"vue": "3.2.37",
|
||||||
"vue-gettext": "2.1.12",
|
"vue-gettext": "2.1.12",
|
||||||
"vue-plyr": "7.0.0",
|
"vue-plyr": "7.0.0",
|
||||||
"vue-router": "4.1.2",
|
"vue-router": "4.1.2",
|
||||||
"vue-tsc": "0.38.9",
|
"vue-tsc": "0.39.0",
|
||||||
"vue-upload-component": "3.1.2",
|
"vue-upload-component": "3.1.2",
|
||||||
"vue-virtual-scroller": "^2.0.0-alpha.1",
|
"vue-virtual-scroller": "^2.0.0-alpha.1",
|
||||||
"vue3-gettext": "2.3.0",
|
"vue3-gettext": "2.3.0",
|
||||||
|
@ -66,7 +70,7 @@
|
||||||
"@typescript-eslint/eslint-plugin": "5.30.7",
|
"@typescript-eslint/eslint-plugin": "5.30.7",
|
||||||
"@vitejs/plugin-vue": "3.0.1",
|
"@vitejs/plugin-vue": "3.0.1",
|
||||||
"@vue/compiler-sfc": "3.2.37",
|
"@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/eslint-config-typescript": "11.0.0",
|
||||||
"@vue/test-utils": "2.0.2",
|
"@vue/test-utils": "2.0.2",
|
||||||
"@vue/tsconfig": "0.1.3",
|
"@vue/tsconfig": "0.1.3",
|
||||||
|
@ -79,14 +83,15 @@
|
||||||
"eslint-plugin-n": "15.2.4",
|
"eslint-plugin-n": "15.2.4",
|
||||||
"eslint-plugin-node": "11.1.0",
|
"eslint-plugin-node": "11.1.0",
|
||||||
"eslint-plugin-promise": "6.0.0",
|
"eslint-plugin-promise": "6.0.0",
|
||||||
"eslint-plugin-vue": "9.2.0",
|
"eslint-plugin-vue": "9.3.0",
|
||||||
"jest-cli": "28.1.3",
|
"jest-cli": "28.1.3",
|
||||||
"moxios": "0.4.0",
|
"moxios": "0.4.0",
|
||||||
"sinon": "14.0.0",
|
"sinon": "14.0.0",
|
||||||
"ts-jest": "28.0.7",
|
"ts-jest": "28.0.7",
|
||||||
"typescript": "4.7.4",
|
"typescript": "4.7.4",
|
||||||
"vite": "3.0.2",
|
"vite": "3.0.3",
|
||||||
"vite-plugin-pwa": "0.12.3",
|
"vite-plugin-pwa": "0.12.3",
|
||||||
|
"vite-plugin-vue-inspector": "1.0.1",
|
||||||
"vue-jest": "3.0.7",
|
"vue-jest": "3.0.7",
|
||||||
"workbox-core": "6.5.3",
|
"workbox-core": "6.5.3",
|
||||||
"workbox-precaching": "6.5.3",
|
"workbox-precaching": "6.5.3",
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { get } from 'lodash-es'
|
import { get } from 'lodash-es'
|
||||||
import showdown from 'showdown'
|
import useMarkdown from '~/composables/useMarkdown'
|
||||||
import { humanSize } from '~/utils/filters'
|
import { humanSize } from '~/utils/filters'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useGettext } from 'vue3-gettext'
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
|
||||||
const markdown = new showdown.Converter()
|
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
const nodeinfo = computed(() => store.state.instance.nodeinfo)
|
||||||
|
|
||||||
|
@ -18,9 +16,9 @@ const labels = computed(() => ({
|
||||||
|
|
||||||
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
|
const podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
|
||||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||||
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 rules = useMarkdown(() => get(nodeinfo.value, 'metadata.rules'))
|
||||||
const terms = computed(() => get(nodeinfo.value, 'metadata.terms'))
|
const terms = useMarkdown(() => get(nodeinfo.value, 'metadata.terms'))
|
||||||
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
||||||
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
||||||
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
|
const allowListEnabled = computed(() => get(nodeinfo.value, 'metadata.allowList.enabled'))
|
||||||
|
@ -140,7 +138,7 @@ const headerStyle = computed(() => {
|
||||||
</h2>
|
</h2>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="longDescription"
|
v-if="longDescription"
|
||||||
:html="markdown.makeHtml(longDescription)"
|
:html="longDescription"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
<translate translate-context="Content/About/Paragraph">
|
<translate translate-context="Content/About/Paragraph">
|
||||||
|
@ -158,7 +156,7 @@ const headerStyle = computed(() => {
|
||||||
</h3>
|
</h3>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="rules"
|
v-if="rules"
|
||||||
:html="markdown.makeHtml(rules)"
|
:html="rules"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
<translate translate-context="Content/About/Paragraph">
|
<translate translate-context="Content/About/Paragraph">
|
||||||
|
@ -176,7 +174,7 @@ const headerStyle = computed(() => {
|
||||||
</h3>
|
</h3>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="terms"
|
v-if="terms"
|
||||||
:html="markdown.makeHtml(terms)"
|
:html="terms"
|
||||||
/>
|
/>
|
||||||
<p v-else>
|
<p v-else>
|
||||||
<translate translate-context="Content/About/Paragraph">
|
<translate translate-context="Content/About/Paragraph">
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { get } from 'lodash-es'
|
import { get } from 'lodash-es'
|
||||||
import showdown from 'showdown'
|
|
||||||
import AlbumWidget from '~/components/audio/album/Widget.vue'
|
import AlbumWidget from '~/components/audio/album/Widget.vue'
|
||||||
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
import ChannelsWidget from '~/components/audio/ChannelsWidget.vue'
|
||||||
import LoginForm from '~/components/auth/LoginForm.vue'
|
import LoginForm from '~/components/auth/LoginForm.vue'
|
||||||
import SignupForm from '~/components/auth/SignupForm.vue'
|
import SignupForm from '~/components/auth/SignupForm.vue'
|
||||||
|
import useMarkdown from '~/composables/useMarkdown'
|
||||||
import { humanSize } from '~/utils/filters'
|
import { humanSize } from '~/utils/filters'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
@ -12,8 +12,6 @@ import { whenever } from '@vueuse/core'
|
||||||
import { useGettext } from 'vue3-gettext'
|
import { useGettext } from 'vue3-gettext'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const markdown = new showdown.Converter()
|
|
||||||
|
|
||||||
const { $pgettext } = useGettext()
|
const { $pgettext } = useGettext()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
title: $pgettext('Head/Home/Title', 'Home')
|
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 podName = computed(() => get(nodeinfo.value, 'metadata.nodeName') || 'Funkwhale')
|
||||||
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
const banner = computed(() => get(nodeinfo.value, 'metadata.banner'))
|
||||||
const shortDescription = computed(() => get(nodeinfo.value, 'metadata.shortDescription'))
|
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 rules = computed(() => get(nodeinfo.value, 'metadata.rules'))
|
||||||
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
const contactEmail = computed(() => get(nodeinfo.value, 'metadata.contactEmail'))
|
||||||
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
const anonymousCanListen = computed(() => get(nodeinfo.value, 'metadata.library.anonymousCanListen'))
|
||||||
|
@ -111,7 +109,7 @@ whenever(() => store.state.auth.authenticated, () => {
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="longDescription"
|
v-if="longDescription"
|
||||||
id="renderedDescription"
|
id="renderedDescription"
|
||||||
:html="markdown.makeHtml(longDescription)"
|
:html="longDescription"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="longDescription"
|
v-if="longDescription"
|
||||||
|
|
|
@ -11,6 +11,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
tag: 'div'
|
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 html = computed(() => DOMPurify.sanitize(props.html))
|
||||||
const root = () => h(props.tag, { innerHTML: html.value })
|
const root = () => h(props.tag, { innerHTML: html.value })
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -46,7 +46,7 @@ const fetchData = async (url = props.url) => {
|
||||||
count.value = response.data.count
|
count.value = response.data.count
|
||||||
|
|
||||||
const newObjects = !props.isActivity
|
const newObjects = !props.isActivity
|
||||||
? response.data.results.map((track: Track) => { track })
|
? response.data.results.map((track: Track) => ({ track }))
|
||||||
: response.data.results
|
: response.data.results
|
||||||
|
|
||||||
objects.push(...newObjects)
|
objects.push(...newObjects)
|
||||||
|
@ -62,7 +62,7 @@ fetchData()
|
||||||
const emit = defineEmits(['count'])
|
const emit = defineEmits(['count'])
|
||||||
watch(count, (to) => emit('count', to))
|
watch(count, (to) => emit('count', to))
|
||||||
|
|
||||||
if (props.websocketHandlers.includes('Listen')) {
|
watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||||
useWebSocketHandler('Listen', (event) => {
|
useWebSocketHandler('Listen', (event) => {
|
||||||
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
|
// TODO (wvffle): Add reactivity to recently listened / favorited / added (#1316, #1534)
|
||||||
// count.value += 1
|
// count.value += 1
|
||||||
|
@ -70,7 +70,7 @@ if (props.websocketHandlers.includes('Listen')) {
|
||||||
// objects.unshift(event as Listening)
|
// objects.unshift(event as Listening)
|
||||||
// objects.pop()
|
// objects.pop()
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<template>
|
||||||
<form
|
<form
|
||||||
:class="['ui segment form', {loading: isLoading}]"
|
:class="['ui segment form', {loading: isLoading}]"
|
||||||
|
@ -6,7 +60,7 @@
|
||||||
<h3>{{ plugin.label }}</h3>
|
<h3>{{ plugin.label }}</h3>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="plugin.description"
|
v-if="plugin.description"
|
||||||
:html="markdown.makeHtml(plugin.description)"
|
:html="description"
|
||||||
/>
|
/>
|
||||||
<template v-if="plugin.homepage">
|
<template v-if="plugin.homepage">
|
||||||
<div class="ui small hidden divider" />
|
<div class="ui small hidden divider" />
|
||||||
|
@ -74,8 +128,8 @@
|
||||||
</div>
|
</div>
|
||||||
<template v-if="plugin.conf?.length > 0">
|
<template v-if="plugin.conf?.length > 0">
|
||||||
<template
|
<template
|
||||||
v-for="(field, key) in plugin.conf"
|
v-for="field in plugin.conf"
|
||||||
:key="key"
|
:key="field.name"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="field.type === 'text'"
|
v-if="field.type === 'text'"
|
||||||
|
@ -89,7 +143,7 @@
|
||||||
>
|
>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="markdown.makeHtml(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -105,7 +159,7 @@
|
||||||
/>
|
/>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="markdown.makeHtml(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -120,7 +174,7 @@
|
||||||
>
|
>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="markdown.makeHtml(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -135,7 +189,7 @@
|
||||||
>
|
>
|
||||||
<sanitized-html
|
<sanitized-html
|
||||||
v-if="field.help"
|
v-if="field.help"
|
||||||
:html="markdown.makeHtml(field.help)"
|
:html="useMarkdownRaw(field.help)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -150,7 +204,6 @@
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="plugin.source"
|
v-if="plugin.source"
|
||||||
type="scan"
|
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||||
@click.prevent="submitAndScan"
|
@click.prevent="submitAndScan"
|
||||||
>
|
>
|
||||||
|
@ -161,54 +214,3 @@
|
||||||
<div class="ui clearing hidden divider" />
|
<div class="ui clearing hidden divider" />
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="content-form ui segments">
|
<div class="content-form ui segments">
|
||||||
<div class="ui segment">
|
<div class="ui segment">
|
||||||
|
@ -31,7 +110,7 @@
|
||||||
<div class="line" />
|
<div class="line" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else-if="preview === null">
|
<p v-else-if="!preview">
|
||||||
<translate translate-context="*/Form/Paragraph">
|
<translate translate-context="*/Form/Paragraph">
|
||||||
Nothing to preview.
|
Nothing to preview.
|
||||||
</translate>
|
</translate>
|
||||||
|
@ -44,13 +123,10 @@
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="ui transparent input">
|
<div class="ui transparent input">
|
||||||
<textarea
|
<textarea
|
||||||
:id="fieldId"
|
|
||||||
ref="textarea"
|
ref="textarea"
|
||||||
v-model="newValue"
|
v-model="value"
|
||||||
:name="fieldId"
|
:required="required"
|
||||||
:rows="rows"
|
:placeholder="labels.placeholder"
|
||||||
:required="required || null"
|
|
||||||
:placeholder="placeholder || labels.placeholder"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui very small hidden divider" />
|
<div class="ui very small hidden divider" />
|
||||||
|
@ -71,82 +147,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -64,10 +78,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="markdown && object.summary">
|
<div v-if="summary">
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<p><strong><translate translate-context="Content/Moderation/*/Noun">Reason</translate></strong></p>
|
<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>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<button
|
<button
|
||||||
|
@ -81,21 +95,3 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<form
|
<form
|
||||||
class="ui form"
|
class="ui form"
|
||||||
|
@ -34,7 +81,7 @@
|
||||||
<button
|
<button
|
||||||
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || null"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
<translate translate-context="Content/Moderation/Button.Label/Verb">
|
<translate translate-context="Content/Moderation/Button.Label/Verb">
|
||||||
Add note
|
Add note
|
||||||
|
@ -42,48 +89,3 @@
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</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 type { Note } from '~/types'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import showdown from 'showdown'
|
import { useMarkdownRaw } from '~/composables/useMarkdown'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -11,8 +11,6 @@ interface Props {
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const markdown = new showdown.Converter()
|
|
||||||
|
|
||||||
const emit = defineEmits(['deleted'])
|
const emit = defineEmits(['deleted'])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const remove = async (note: Note) => {
|
const remove = async (note: Note) => {
|
||||||
|
@ -51,7 +49,7 @@ const remove = async (note: Note) => {
|
||||||
</div>
|
</div>
|
||||||
<div class="extra text">
|
<div class="extra text">
|
||||||
<expandable-div :content="note.summary">
|
<expandable-div :content="note.summary">
|
||||||
<sanitized-html :html="markdown.makeHtml(note.summary)" />
|
<sanitized-html :html="useMarkdownRaw(note.summary ?? '')" />
|
||||||
</expandable-div>
|
</expandable-div>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<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>
|
<template>
|
||||||
<div class="ui fluid report card">
|
<div class="ui fluid report card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
@ -48,7 +183,7 @@
|
||||||
<td>
|
<td>
|
||||||
<report-category-dropdown
|
<report-category-dropdown
|
||||||
v-model="obj.type"
|
v-model="obj.type"
|
||||||
@update:model-value="update({ type: $event })"
|
@update:model-value="update($event)"
|
||||||
>
|
>
|
||||||
 
|
 
|
||||||
<action-feedback :is-loading="updating.type" />
|
<action-feedback :is-loading="updating.type" />
|
||||||
|
@ -163,11 +298,11 @@
|
||||||
</translate>
|
</translate>
|
||||||
</h3>
|
</h3>
|
||||||
<expandable-div
|
<expandable-div
|
||||||
v-if="obj.summary"
|
v-if="summary"
|
||||||
class="summary"
|
class="summary"
|
||||||
:content="obj.summary"
|
:content="obj.summary"
|
||||||
>
|
>
|
||||||
<sanitized-html :html="markdown.makeHtml(obj.summary)" />
|
<sanitized-html :html="summary" />
|
||||||
</expandable-div>
|
</expandable-div>
|
||||||
</div>
|
</div>
|
||||||
<aside class="column">
|
<aside class="column">
|
||||||
|
@ -342,7 +477,7 @@
|
||||||
<button
|
<button
|
||||||
v-if="obj.is_handled === false"
|
v-if="obj.is_handled === false"
|
||||||
:class="['ui', {loading: isLoading}, 'button']"
|
:class="['ui', {loading: isLoading}, 'button']"
|
||||||
@click="resolve(true)"
|
@click="resolveReport(true)"
|
||||||
>
|
>
|
||||||
<i class="success check icon" />
|
<i class="success check icon" />
|
||||||
<translate translate-context="Content/*/Button.Label/Verb">
|
<translate translate-context="Content/*/Button.Label/Verb">
|
||||||
|
@ -352,7 +487,7 @@
|
||||||
<button
|
<button
|
||||||
v-if="obj.is_handled === true"
|
v-if="obj.is_handled === true"
|
||||||
:class="['ui', {loading: isLoading}, 'button']"
|
:class="['ui', {loading: isLoading}, 'button']"
|
||||||
@click="resolve(false)"
|
@click="resolveReport(false)"
|
||||||
>
|
>
|
||||||
<i class="warning redo icon" />
|
<i class="warning redo icon" />
|
||||||
<translate translate-context="Content/*/Button.Label">
|
<translate translate-context="Content/*/Button.Label">
|
||||||
|
@ -389,174 +524,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="ui fluid user-request card">
|
<div class="ui fluid user-request card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
@ -157,12 +220,12 @@
|
||||||
<template v-if="obj.metadata">
|
<template v-if="obj.metadata">
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<div
|
<div
|
||||||
v-for="k in Object.keys(obj.metadata)"
|
v-for="(value, key) in obj.metadata"
|
||||||
:key="k"
|
:key="key"
|
||||||
>
|
>
|
||||||
<h4>{{ k }}</h4>
|
<h4>{{ key }}</h4>
|
||||||
<p v-if="obj.metadata[k] && obj.metadata[k].length">
|
<p v-if="isArray(value)">
|
||||||
{{ obj.metadata[k] }}
|
{{ value }}
|
||||||
</p>
|
</p>
|
||||||
<translate
|
<translate
|
||||||
v-else
|
v-else
|
||||||
|
@ -222,52 +285,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
import { gettext } from '~/init/locale'
|
import { gettext } from '~/init/locale'
|
||||||
|
@ -19,7 +20,6 @@ export interface Entity {
|
||||||
moderatedFields: ModeratedField[]
|
moderatedFields: ModeratedField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EntityObjectType = 'artist' | 'album' | 'track' | 'library' | 'playlist' | 'account' | 'channel'
|
|
||||||
type Configs = Record<EntityObjectType, Entity>
|
type Configs = Record<EntityObjectType, Entity>
|
||||||
|
|
||||||
const { $pgettext } = gettext
|
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 {
|
.content-form {
|
||||||
background: var(--input-background);
|
background: var(--input-background);
|
||||||
|
|
||||||
.segment {
|
.segment {
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.segment:first-child {
|
.segment:first-child {
|
||||||
min-height: 15em;
|
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 {
|
.ui.secondary.menu {
|
||||||
background: none;
|
background: none;
|
||||||
margin-top: -0.5em;
|
margin-top: -0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,6 +242,10 @@ export interface PendingReviewRequestsWSEvent {
|
||||||
pending_count: number
|
pending_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InboxItemAddedWSEvent {
|
||||||
|
item: Notification
|
||||||
|
}
|
||||||
|
|
||||||
export interface ListenWsEventObject {
|
export interface ListenWsEventObject {
|
||||||
local_id: string
|
local_id: string
|
||||||
}
|
}
|
||||||
|
@ -256,7 +260,7 @@ export interface ListenWSEvent {
|
||||||
// type: 'Listen'
|
// type: 'Listen'
|
||||||
// }
|
// }
|
||||||
|
|
||||||
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent
|
export type WebSocketEvent = PendingReviewEditsWSEvent | PendingReviewReportsWSEvent | PendingReviewRequestsWSEvent | ListenWSEvent | InboxItemAddedWSEvent
|
||||||
|
|
||||||
// FS Browser
|
// FS Browser
|
||||||
export interface FSEntry {
|
export interface FSEntry {
|
||||||
|
@ -374,7 +378,96 @@ export interface SettingsDataEntry {
|
||||||
// Note stuff
|
// Note stuff
|
||||||
export interface Note {
|
export interface Note {
|
||||||
uuid: string
|
uuid: string
|
||||||
author: Actor // TODO (wvffle): Check if is valid
|
type: 'request' | 'report'
|
||||||
summary: string
|
author?: Actor // TODO (wvffle): Check if is valid
|
||||||
creation_date: string
|
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 { APIErrorResponse } from '~/types'
|
||||||
import type { RootState } from '~/store'
|
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[] {
|
export function parseAPIErrors (responseData: APIErrorResponse, parentField?: string): string[] {
|
||||||
const errors = []
|
const errors = []
|
||||||
for (const [field, value] of Object.entries(responseData)) {
|
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>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
|
@ -25,7 +114,7 @@
|
||||||
Support this Funkwhale pod
|
Support this Funkwhale pod
|
||||||
</translate>
|
</translate>
|
||||||
</h4>
|
</h4>
|
||||||
<sanitized-html :html="markdown.makeHtml($store.state.instance.settings.instance.support_message.value)" />
|
<sanitized-html :html="supportMessage" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ui bottom attached segment">
|
<div class="ui bottom attached segment">
|
||||||
<form
|
<form
|
||||||
|
@ -210,104 +299,3 @@
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
<template v-if="object">
|
<template v-if="object">
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<report-card :obj="object" />
|
<report-card :init-obj="object" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// import type { HmrOptions } from 'vite'
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import Vue from '@vitejs/plugin-vue'
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
import Inspector from 'vite-plugin-vue-inspector'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { resolve } from 'path'
|
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
|
// https://github.com/antfu/vite-plugin-pwa
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
filename: 'serviceWorker.ts',
|
filename: 'serviceWorker.ts',
|
||||||
|
manifestFilename: 'manifest.json',
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
type: 'module',
|
type: 'module',
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html'
|
||||||
webManifestUrl: '/front/manifest.json'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|
900
front/yarn.lock
900
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue