fix(ui): artist and album detail and edit pages

This commit is contained in:
ArneBo 2025-01-15 16:18:31 +01:00
parent 0bdb026cf1
commit a84868e1df
19 changed files with 577 additions and 506 deletions

View File

@ -2,6 +2,8 @@
import type { ArtistCredit } from '~/types'
import { useStore } from '~/store'
import Link from '~/components/ui/Link.vue'
const store = useStore()
interface Props {
@ -26,7 +28,7 @@ const getRoute = (ac: ArtistCredit) => {
v-for="ac in props.artistCredit"
:key="ac.artist.id"
>
<router-link
<Link solid secondary round min-content
:to="getRoute(ac)"
>
<img
@ -37,10 +39,10 @@ const getRoute = (ac: ArtistCredit) => {
>
<i
v-else-if="ac.index === 0"
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
:class="[ac.artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet bi bi-users icon']"
/>
{{ ac.credit }}
</router-link>
</Link>
<span>{{ ac.joinphrase }}</span>
</template>
</div>

View File

@ -10,10 +10,11 @@ import { useStore } from '~/store'
import axios from 'axios'
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
import Pagination from '~/components/vui/Pagination.vue'
import Pagination from '~/components/ui/Pagination.vue'
import TrackRow from '~/components/audio/track/Row.vue'
import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import useErrorHandler from '~/composables/useErrorHandler'
@ -175,18 +176,13 @@ const updatePage = (page: number) => {
<div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<Loader v-if="isLoading" />
<div class="track-table row">
<div
v-if="showPosition"
class="actions left floated column"
>
<i class="hashtag icon" />
<i class="bi bi-hash" />
</div>
<div
v-else
@ -220,7 +216,7 @@ const updatePage = (page: number) => {
class="meta right floated column"
>
<i
class="clock outline icon"
class="bi bi-clock"
style="padding: 0.5rem"
/>
</div>
@ -263,12 +259,7 @@ const updatePage = (page: number) => {
<div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<Loader v-if="isLoading" />
<!-- For each item, build a row -->
@ -289,7 +280,7 @@ const updatePage = (page: number) => {
v-if="tracks && paginateResults && totalTracks > paginateBy"
class="ui center aligned basic segment tablet-and-below"
>
<pagination
<Pagination
v-if="paginateResults && totalTracks > paginateBy"
:paginate-by="paginateBy"
:total="totalTracks"

View File

@ -27,3 +27,16 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${actorColor.val
class="ui avatar circular label"
>{{ actor.preferred_username?.[0] || "" }}</span>
</template>
<style lang="scss">
.ui.circular.avatar {
float: left;
margin-right: 8px;
text-align: center;
border-radius: 50%;
&.label {
align-content: center;
}
}
</style>

View File

@ -5,6 +5,8 @@ import { toRefs } from '@vueuse/core'
import { computed } from 'vue'
import { truncate } from '~/utils/filters'
import Link from '~/components/ui/Link.vue'
interface Props {
actor: Actor
avatar?: boolean
@ -50,15 +52,18 @@ const url = computed(() => {
</script>
<template>
<router-link
<Link
:to="url"
:title="actor.full_username"
class="username"
solid
secondary
round
>
<actor-avatar
v-if="avatar"
:actor="actor"
/>
<span>&nbsp;</span>
<slot>{{ repr }}</slot>
</router-link>
</Link>
</template>

View File

@ -8,6 +8,9 @@ import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import useFormData from '~/composables/useFormData'
import Button from '~/components/ui/Button.vue'
import Alert from '~/components/ui/Alert.vue'
interface Events {
(e: 'update:modelValue', value: string | null): void
(e: 'delete'): void
@ -104,10 +107,9 @@ const getAttachmentUrl = (uuid: string) => {
<template>
<div class="ui form">
<div
<Alert red
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ t('components.common.AttachmentInput.header.failure') }}
@ -120,7 +122,7 @@ const getAttachmentUrl = (uuid: string) => {
{{ error }}
</li>
</ul>
</div>
</Alert>
<div class="ui field">
<span id="avatarLabel">
<slot />
@ -158,19 +160,19 @@ const getAttachmentUrl = (uuid: string) => {
type="file"
accept="image/png,image/jpeg"
@change="submit"
>
/>
</div>
<div class="ui very small hidden divider" />
<p>
{{ t('components.common.AttachmentInput.help.upload') }}
</p>
<button
<Button
v-if="value"
class="ui basic tiny button"
@click.stop.prevent="remove(value as string)"
>
{{ t('components.common.AttachmentInput.button.remove') }}
</button>
</Button>
<div
v-if="isLoading"
class="ui active inverted dimmer"

View File

@ -86,57 +86,55 @@ onMounted(async () => {
</script>
<template>
<Button
@click.prevent="isPreviewing = false"
:aria-pressed="!isPreviewing"
title="write"
>
{{ t('components.common.ContentForm.button.write') }}
</Button>
<Button
@click.prevent="isPreviewing = true"
:aria-pressed="!isPreviewing"
title="preview"
>
{{ t('components.common.ContentForm.button.preview') }}
</Button>
<template v-if="isPreviewing">
<div
v-if="isLoadingPreview"
class="ui placeholder"
>
<div class="paragraph">
<div class="line" />
<div class="line" />
<div class="line" />
<div class="line" />
</div>
</div>
<p v-else-if="!preview">
{{ t('components.common.ContentForm.empty.noContent') }}
</p>
<sanitized-html
v-else
:html="preview"
/>
</template>
<template v-else>
<div class="ui transparent input">
<Textarea
ref="textarea"
v-model="value"
:required="required"
:placeholder="labels.placeholder"
/>
</div>
</template>
<span
v-if="charLimit"
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
>
{{ remainingChars }}
</span>
<p>
{{ t('components.common.ContentForm.help.markdown') }}
</p>
<Button
@click.prevent="isPreviewing = false"
:aria-pressed="!isPreviewing"
title="write"
>
{{ t('components.common.ContentForm.button.write') }}
</Button>
<Button
@click.prevent="isPreviewing = true"
:aria-pressed="!isPreviewing"
title="preview"
>
{{ t('components.common.ContentForm.button.preview') }}
</Button>
<template v-if="isPreviewing">
<div
v-if="isLoadingPreview"
class="ui placeholder"
>
<div class="paragraph">
<div class="line" />
<div class="line" />
<div class="line" />
<div class="line" />
</div>
</div>
<p v-else-if="!preview">
{{ t('components.common.ContentForm.empty.noContent') }}
</p>
<sanitized-html
v-else
:html="preview"
/>
</template>
<template v-else>
<Textarea
ref="textarea"
v-model="value"
:required="required"
:placeholder="labels.placeholder"
/>
</template>
<span
v-if="charLimit"
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
>
{{ remainingChars }}
</span>
<p>
{{ t('components.common.ContentForm.help.markdown') }}
</p>
</template>

View File

@ -2,6 +2,10 @@
import { toRefs, useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue'
import Alert from '~/components/ui/Alert.vue'
interface Props {
value: string
buttonClasses?: string
@ -20,27 +24,53 @@ const { copy, isSupported: canCopy, copied } = useClipboard({ source: value, cop
</script>
<template>
<div class="ui fluid action input component-copy-input">
<p
v-if="copied"
class="message"
>
{{ t('components.common.CopyInput.message.success') }}
</p>
<input
<Input
:id="id"
:value="value"
readonly
:name="id"
type="text"
readonly
>
<button
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"
:disabled="!canCopy || undefined"
@click="copy()"
>
<i class="copy icon" />
{{ t('components.common.CopyInput.button.copy') }}
</button>
</div>
<template #input-right>
<Button
:class="['ui', buttonClasses, 'input-right']"
min-content
secondary
:disabled="!canCopy || undefined"
@click="copy()"
>
<i class="bi bi-copy" />
{{ t('components.common.CopyInput.button.copy') }}
</Button>
</template>
</Input>
</template>
<style scoped>
.input-right {
position: absolute;
right: 0px;
bottom: 0px;
height: 48px;
min-width: 48px;
display: flex;
.button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
p.message {
background-color: var(--hover-background-color);
padding: 8px;
position: absolute;
bottom: -32px;
right: 0px;
}
</style>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import SemanticModal from '~/components/semantic/Modal.vue'
import Button from '~/components/ui/Button.vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SemanticModal from '~/components/semantic/Modal.vue'
import Button from '~/components/ui/Button.vue'
interface Events {
(e: 'confirm'): void
}
@ -33,16 +34,17 @@ const confirm = () => {
</script>
<template>
<button
class="funkwhale dangerous-button"
<Button
destructive
:class="{ 'is-disabled': disabled }"
:disabled="disabled"
@click.prevent.stop="showModal = true"
>
<slot />
<semantic-modal
<SemanticModal
v-model:show="showModal"
:title="t('components.common.DangerousButton.header.confirm')"
class="small"
>
<h4 class="header">
@ -55,10 +57,10 @@ const confirm = () => {
<slot name="modal-content" />
</div>
</div>
<div class="actions">
<template #actions>
<Button
color="secondary"
variant="outline"
secondary
outline
@click="showModal = false"
>
{{ t('components.common.DangerousButton.button.cancel') }}
@ -71,7 +73,7 @@ const confirm = () => {
{{ t('components.common.DangerousButton.button.confirm') }}
</slot>
</Button>
</div>
</semantic-modal>
</button>
</template>
</SemanticModal>
</Button>
</template>

View File

@ -25,8 +25,15 @@ const realDate = useTimeAgo(date)
>
<i
v-if="props.icon"
class="outline clock icon"
class="bi bi-clock"
/>
{{ realDate }}
</time>
</template>
<style scoped>
i {
margin-right: 8px;
font-size: 14px;
}
</style>

View File

@ -14,6 +14,9 @@ import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import TagsList from '~/components/tags/List.vue'
import AlbumDropdown from './AlbumDropdown.vue'
import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Loader from '~/components/ui/Loader.vue'
import useErrorHandler from '~/composables/useErrorHandler'
import useLogger from '~/composables/useLogger'
@ -136,19 +139,15 @@ const remove = async () => {
</script>
<template>
<main>
<div
<Layout stack main>
<Loader
v-if="isLoading"
v-title="labels.title"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
/>
<template v-if="object">
<section class="ui vertical stripe segment channel-serie">
<div class="ui stackable grid container">
<div class="ui seven wide column">
/front/src/components/library/AlbumBase.vue
<section class="segment channel-serie">
<Layout flex gap-64 style="align-content: stretch;">
<div style="flex: 2;">
<div
v-if="isSerie"
class="padded basic segment"
@ -204,8 +203,7 @@ const remove = async () => {
{{ t('components.library.AlbumBase.meta.tracks', totalTracks) }}
</span>
</template>
<div class="ui small hidden divider" />
<play-button
<PlayButton
class="vibrant"
:tracks="object.tracks"
:is-playable="object.is_playable"
@ -223,7 +221,6 @@ const remove = async () => {
/>
</div>
</div>
<div class="ui small hidden divider" />
<header>
<h2
class="ui header"
@ -266,6 +263,7 @@ const remove = async () => {
:artist-credit="artistCredit"
/>
</header>
<Spacer :size="16"/>
<div
v-if="object.release_date || (totalTracks > 0)"
class="ui small hidden divider"
@ -342,7 +340,7 @@ const remove = async () => {
</router-link>
</template>
</div>
<div class="nine wide column">
<div style="flex 1;">
<router-view
v-if="object"
:key="route.fullPath"
@ -356,8 +354,8 @@ const remove = async () => {
@libraries-loaded="libraries = $event"
/>
</div>
</div>
</Layout>
</section>
</template>
</main>
</Layout>
</template>

View File

@ -56,7 +56,6 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
<template>
<div
v-if="!isLoadingTracks"
class="ui vertical segment"
>
<h2 class="ui header">
<span v-if="isSerie">
@ -82,7 +81,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
>
<template v-if="tracks.length > 0">
<div class="ui hidden divider" />
<play-button
<PlayButton
class="right floated mini inverted vibrant"
:tracks="discs[index]"
/>

View File

@ -15,6 +15,7 @@ import Button from '~/components/ui/Button.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import DangerousButton from '~/components/common/DangerousButton.vue'
import OptionsButton from "~/components/ui/button/Options.vue"
interface Events {
(e: 'remove'): void
@ -55,7 +56,6 @@ const open = ref(false)
<template>
<span>
<semantic-modal
v-if="isEmbedable"
v-model:show="showEmbedModal"
@ -84,11 +84,7 @@ const open = ref(false)
</semantic-modal>
<Popover v-model:open="open">
<template #default="{ toggleOpen }">
<Button
variant="ghost"
color="secondary"
round
icon="bi-three-dots-vertical"
<OptionsButton
:title="labels.more"
@click="toggleOpen()"
/>

View File

@ -21,27 +21,23 @@ const canEdit = store.state.auth.availablePermissions.library
</script>
<template>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h2>
<span v-if="canEdit">
{{ t('components.library.AlbumEdit.header.edit') }}
</span>
<span v-else>
{{ t('components.library.AlbumEdit.header.suggest') }}
</span>
</h2>
<div
v-if="!object.is_local"
class="ui message"
>
{{ t('components.library.AlbumEdit.message.remote') }}
</div>
<edit-form
v-else
:object-type="objectType"
:object="object"
/>
</div>
</section>
<h2>
<span v-if="canEdit">
{{ t('components.library.AlbumEdit.header.edit') }}
</span>
<span v-else>
{{ t('components.library.AlbumEdit.header.suggest') }}
</span>
</h2>
<div
v-if="!object.is_local"
class="ui message"
>
{{ t('components.library.AlbumEdit.message.remote') }}
</div>
<edit-form
v-else
:object-type="objectType"
:object="object"
/>
</template>

View File

@ -13,7 +13,9 @@ import useLogger from '~/composables/useLogger'
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
import SemanticModal from '~/components/semantic/Modal.vue'
import Loader from '~/components/ui/Loader.vue'
import Button from '~/components/ui/Button.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import RadioButton from '~/components/radios/Button.vue'
import Popover from '~/components/ui/Popover.vue'
@ -98,16 +100,11 @@ watch(() => props.id, fetchData, { immediate: true })
<template>
<main v-title="labels.title">
<div
v-if="isLoading"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<Loader v-if="isLoading" />
<template v-if="object && !isLoading">
<section
v-title="object.name"
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned']"
:style="headerStyle"
>
<div class="segment-content">
@ -167,104 +164,99 @@ watch(() => props.id, fetchData, { immediate: true })
</Button>
</div>
</semantic-modal>
<div class="ui buttons">
<Popover>
<template #default="{ toggleOpen }">
<Button
@click="toggleOpen"
icon="caret-down-fill"
>
{{ t('components.library.ArtistBase.button.more') }}
</Button>
</template>
<Popover>
<template #default="{ toggleOpen }">
<OptionsButton
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-if="domain != store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="funkwhale item"
>
<i class="external icon" />
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</PopoverItem>
<template #items>
<PopoverItem
v-if="domain != store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="funkwhale item"
>
<i class="bi bi-box-arrow-up-right" />
{{ t('components.library.ArtistBase.link.domain', {domain: domain}) }}
</PopoverItem>
<PopoverItem
v-if="publicLibraries.length > 0"
@click="showEmbedModal = !showEmbedModal"
>
<i class="code icon" />
{{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem>
<PopoverItem
v-if="publicLibraries.length > 0"
@click="showEmbedModal = !showEmbedModal"
>
<i class="bi bi-code-square" />
{{ t('components.library.ArtistBase.button.embed') }}
</PopoverItem>
<PopoverItem
:href="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="wikipedia w icon" />
{{ t('components.library.ArtistBase.link.wikipedia') }}
</PopoverItem>
<PopoverItem
:href="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="bi bi-wikipedia" />
{{ t('components.library.ArtistBase.link.wikipedia') }}
</PopoverItem>
<PopoverItem
v-if="musicbrainzUrl"
:href="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="external icon" />
{{ t('components.library.ArtistBase.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
v-if="musicbrainzUrl"
:href="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="bi bi-box-arrow-up-right" />
{{ t('components.library.ArtistBase.link.musicbrainz') }}
</PopoverItem>
<PopoverItem
:href="discogsUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="external icon" />
{{ t('components.library.ArtistBase.link.discogs') }}
</PopoverItem>
<PopoverItem
:href="discogsUrl"
target="_blank"
rel="noreferrer noopener"
>
<i class="bi bi-box-arrow-up-right" />
{{ t('components.library.ArtistBase.link.discogs') }}
</PopoverItem>
<PopoverItem
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
>
<i class="edit icon" />
{{ t('components.library.ArtistBase.button.edit') }}
</PopoverItem>
<PopoverItem
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
>
<i class="bi bi-pencil-fill" />
{{ t('components.library.ArtistBase.button.edit') }}
</PopoverItem>
<hr>
<hr v-for="obj in getReportableObjects({artist: object})">
<PopoverItem
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
@click="report(obj)"
>
<i class="share icon" /> {{ obj.label }}
</PopoverItem>
<PopoverItem
v-for="obj in getReportableObjects({artist: object})"
:key="obj.target.type + obj.target.id"
@click="report(obj)"
>
<i class="bi bi-share-fill" /> {{ obj.label }}
</PopoverItem>
<hr>
<hr>
<PopoverItem
v-if="store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
>
<i class="wrench icon" />
{{ t('components.library.ArtistBase.link.moderation') }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
>
<i class="bi bi-wrench" />
{{ t('components.library.ArtistBase.link.moderation') }}
</PopoverItem>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
{{ t('components.library.ArtistBase.link.django') }}
</PopoverItem>
</template>
</Popover>
</div>
<PopoverItem
v-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
:href="store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank"
rel="noopener noreferrer"
>
<i class="bi bi-wrench" />
{{ t('components.library.ArtistBase.link.django') }}
</PopoverItem>
</template>
</Popover>
</div>
</div>
</section>

View File

@ -3,10 +3,13 @@ import type { EditObjectType } from '~/composables/moderation/useEditConfigs'
import type { Artist, Library } from '~/types'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import EditForm from '~/components/library/EditForm.vue'
import Section from '~/components/ui/Section.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Layout from '~/components/ui/Layout.vue'
import Alert from '~/components/ui/Alert.vue'
interface Props {
objectType: EditObjectType
@ -23,27 +26,23 @@ const canEdit = store.state.auth.availablePermissions.library
</script>
<template>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h2>
<span v-if="canEdit">
{{ t('components.library.ArtistEdit.header.edit') }}
</span>
<span v-else>
{{ t('components.library.ArtistEdit.header.suggest') }}
</span>
</h2>
<div
v-if="!object.is_local"
class="ui message"
>
{{ t('components.library.ArtistEdit.message.remote') }}
</div>
<edit-form
v-else
:object-type="objectType"
:object="object"
/>
</div>
</section>
<Layout stack>
<Spacer />
<Section no-items alignLeft
:h2="canEdit
? t('components.library.ArtistEdit.header.edit')
: t('components.library.ArtistEdit.header.suggest')
"
/>
<Alert yellow
v-if="!object.is_local"
>
{{ t('components.library.ArtistEdit.message.remote') }}
</Alert>
<edit-form
v-else
:object-type="objectType"
:object="object"
/>
</Layout>
</template>

View File

@ -8,13 +8,20 @@ import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { color, setColors } from '~/composables/color'
import axios from 'axios'
import useEditConfigs from '~/composables/moderation/useEditConfigs'
import useErrorHandler from '~/composables/useErrorHandler'
import Button from '~/components/ui/Button.vue'
import DangerousButton from '~/components/common/DangerousButton.vue'
import Card from '~/components/ui/Card.vue'
import Link from '~/components/ui/Link.vue'
import Alert from '~/components/ui/Alert.vue'
import Spacer from '~/components/ui/Spacer.vue'
interface Events {
(e: 'approved', isApproved: boolean): void
(e: 'deleted'): void
@ -155,7 +162,8 @@ const approve = async (approved: boolean) => {
</script>
<template>
<div class="ui fluid card">
<!--TODO: Make "to:" prop on card link the whole card to detailUrl, but leave track link, actor link and action buttons clickable -->
<Card>
<div class="content">
<h4 class="header">
<router-link :to="detailUrl">
@ -166,34 +174,16 @@ const approve = async (approved: boolean) => {
<router-link
v-if="obj.target && obj.target.type === 'track'"
:to="{name: 'library.tracks.detail', params: {id: obj.target.id }}"
:class="isInteractive"
>
<i class="music icon" />
<i class="bi bi-file-music-fill" />
{{ t('components.library.EditCard.link.track', {id: obj.target.id, name: obj.target.repr}) }}
</router-link>
<br>
<human-date
:date="obj.creation_date"
:icon="true"
/>
<span class="right floated">
<span v-if="obj.is_approved && obj.is_applied">
<i class="success check icon" />
{{ t('components.library.EditCard.status.applied') }}
</span>
<span v-else-if="obj.is_approved">
<i class="success check icon" />
{{ t('components.library.EditCard.status.approved') }}
</span>
<span v-else-if="obj.is_approved === null">
<i class="warning hourglass icon" />
{{ t('components.library.EditCard.status.pending') }}
</span>
<span v-else-if="obj.is_approved === false">
<i class="danger x icon" />
{{ t('components.library.EditCard.status.rejected') }}
</span>
</span>
</div>
</div>
<div
@ -202,117 +192,145 @@ const approve = async (approved: boolean) => {
>
{{ obj.summary }}
</div>
<div class="content">
<table
v-if="obj.type === 'update'"
class="ui celled very basic fixed stacking table"
<template #alert>
<!--TODO: Pass Alert colors through to card.vue -->
<Alert
:green="obj.is_approved && obj.is_applied"
:red="obj.is_approved === false"
:yellow="obj.is_approved === null"
>
<thead>
<tr>
<th>
{{ t('components.library.EditCard.table.update.header.field') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.oldValue') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.newValue') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in updatedFields"
:key="field.id"
>
<td>{{ field.id }}</td>
<td v-if="field.diff">
<template v-if="field.config?.type === 'attachment' && field.oldRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.added)"
:key="key"
:class="['diff', {removed: part.removed}]"
>
{{ part.value }}
</span>
</template>
</td>
<td v-else>
{{ t('components.library.EditCard.table.update.notApplicable') }}
</td>
<td
v-if="field.diff"
:title="field.newRepr"
<span class="right floated">
<span v-if="obj.is_approved && obj.is_applied">
<i class="green bi bi-check"/>
{{ t('components.library.EditCard.status.applied') }}
</span>
<span v-else-if="obj.is_approved">
<i class="green bi bi-check" />
{{ t('components.library.EditCard.status.approved') }}
</span>
<span v-else-if="obj.is_approved === null">
<i class="yellow bi bi-hourglass" />
{{ t('components.library.EditCard.status.pending') }}
</span>
<span v-else-if="obj.is_approved === false">
<i class="destructive bi bi-x" />
{{ t('components.library.EditCard.status.rejected') }}
</span>
</span>
<table
v-if="obj.type === 'update'"
>
<thead>
<tr>
<th>
{{ t('components.library.EditCard.table.update.header.field') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.oldValue') }}
</th>
<th>
{{ t('components.library.EditCard.table.update.header.newValue') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in updatedFields"
:key="field.id"
>
<template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.removed)"
:key="key"
:class="['diff', {added: part.added}]"
>
{{ part.value }}
</span>
</template>
</td>
<td
v-else
:title="field.newRepr"
>
<template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
{{ field.newRepr }}
</template>
</td>
</tr>
</tbody>
</table>
</div>
<td>{{ field.id }}</td>
<td v-if="field.diff">
<template v-if="field.config?.type === 'attachment' && field.oldRepr">
<img
class="image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.added)"
:key="key"
:class="['diff', {removed: part.removed}]"
>
{{ part.value }}
</span>
</template>
</td>
<td v-else>
{{ t('components.library.EditCard.table.update.notApplicable') }}
</td>
<td
v-if="field.diff"
:title="field.newRepr"
>
<template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
<span
v-for="(part, key) in field.diff.filter(p => !p.removed)"
:key="key"
:class="['diff', {added: part.added}]"
>
{{ part.value }}
</span>
</template>
</td>
<td
v-else
:title="field.newRepr"
>
<template v-if="field.config?.type === 'attachment' && field.newRepr">
<img
class="ui image"
alt=""
:src="store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
>
</template>
<template v-else>
{{ field.newRepr }}
</template>
</td>
</tr>
</tbody>
</table>
</Alert>
</template>
<div
v-if="obj.created_by"
class="extra content"
>
<!--TODO: Center actor name horizontally -->
<Spacer :size="8"/>
<actor-link :actor="obj.created_by" />
</div>
<div
<template #action
v-if="canDelete || canApprove"
class="ui bottom attached buttons"
>
<button
<Button
v-if="canApprove && obj.is_approved !== true"
:class="['ui', {loading: isLoading}, 'success', 'basic', 'button']"
primary
:isLoading="isLoading"
@click="approve(true)"
>
{{ t('components.library.EditCard.button.approve') }}
</button>
<button
</Button>
<Button
v-if="canApprove && obj.is_approved === null"
:class="['ui', {loading: isLoading}, 'warning', 'basic', 'button']"
destructive
:isLoading="isLoading"
@click="approve(false)"
>
{{ t('components.library.EditCard.button.reject') }}
</button>
</Button>
<!--TODO: Make Dangerous Button hand through isLoading prop -->
<dangerous-button
v-if="canDelete"
:class="['ui', {loading: isLoading}, 'basic danger button']"
@ -337,6 +355,21 @@ const approve = async (approved: boolean) => {
</p>
</template>
</dangerous-button>
</div>
</div>
</template>
</Card>
</template>
<style scoped>
table {
width: 100%;
font-size: 12px;
th, td {
padding: 8px;
text-align: left;
}
.image {
width: 100%;
}
}
</style>

View File

@ -2,19 +2,27 @@
import type { EditObject, EditObjectType } from '~/composables/moderation/useEditConfigs'
import type { BackendError, License, ReviewState } from '~/types'
import { computed, onMounted, reactive, ref, watchEffect } from 'vue'
import { computed, reactive, ref } from 'vue'
import { isEqual, clone } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import axios from 'axios'
import $ from 'jquery'
import AttachmentInput from '~/components/common/AttachmentInput.vue'
import useEditConfigs from '~/composables/moderation/useEditConfigs'
import TagsSelector from '~/components/library/TagsSelector.vue'
import EditList from '~/components/library/EditList.vue'
import EditCard from '~/components/library/EditCard.vue'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue'
import Section from '~/components/ui/Section.vue'
import Spacer from '~/components/ui/Spacer.vue'
import Input from '~/components/ui/Input.vue'
import Textarea from "~/components/ui/Textarea.vue"
import Pills from "~/components/ui/Pills.vue"
import Alert from "~/components/ui/Alert.vue"
interface Props {
objectType: EditObjectType
@ -93,20 +101,6 @@ for (const { id, getValue } of config.value.fields) {
initialValues[id] = clone(values[id])
}
const license = ref()
watchEffect(() => {
if (values.license === null) {
$(license.value).dropdown('clear')
return
}
$(license.value).dropdown('set selected', values.license)
})
onMounted(() => {
$('.ui.dropdown').dropdown({ fullTextSearch: true })
})
const submittedMutation = ref()
const summary = ref('')
@ -145,23 +139,21 @@ const resetField = (fieldId: string) => {
</script>
<template>
<div v-if="submittedMutation">
<div class="ui positive message">
<Alert green v-if="submittedMutation">
<h4 class="header">
{{ t('components.library.EditForm.header.success') }}
</h4>
</div>
<edit-card
:obj="submittedMutation"
:current-state="currentState"
/>
<button
class="ui button"
<Link
solid primary
@click.prevent="submittedMutation = null"
>
{{ t('components.library.EditForm.button.new') }}
</button>
</div>
</Link>
</Alert>
<div v-else>
<edit-list
:filters="editListFilters"
@ -170,6 +162,7 @@ const resetField = (fieldId: string) => {
:current-state="currentState"
>
<div>
<!--TODO: Use Section component with conditional headlines and action buttons-->
<template v-if="showPendingReview">
{{ t('components.library.EditForm.header.unreviewed') }}
<button
@ -195,15 +188,13 @@ const resetField = (fieldId: string) => {
</empty-state>
</template>
</edit-list>
<form
class="ui form"
<Layout form
@submit.prevent="submit()"
>
<div class="ui hidden divider" />
<div
<Alert red
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
{{ t('components.library.EditForm.header.failure') }}
@ -216,13 +207,12 @@ const resetField = (fieldId: string) => {
{{ error }}
</li>
</ul>
</div>
<div
</Alert>
<Alert red
v-if="!canEdit"
class="ui message"
>
{{ t('components.library.EditForm.message.noPermission') }}
</div>
</Alert>
<template v-if="values">
<div
v-for="fieldConfig in config.fields"
@ -230,14 +220,15 @@ const resetField = (fieldId: string) => {
class="ui field"
>
<template v-if="fieldConfig.type === 'text'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<input
<Spacer :size="64"/>
<Input
:id="fieldConfig.id"
v-model="values[fieldConfig.id]"
:type="fieldConfig.inputType || 'text'"
:required="fieldConfig.required"
:name="fieldConfig.id"
>
:label="fieldConfig.label"
/>
</template>
<template v-else-if="fieldConfig.type === 'license'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
@ -260,14 +251,14 @@ const resetField = (fieldId: string) => {
{{ name }}
</option>
</select>
<button
class="ui tiny basic left floated button"
<Button
tiny
icon="bi-x"
form="noop"
@click.prevent="values[fieldConfig.id] = null"
>
<i class="x icon" />
{{ t('components.library.EditForm.button.clear') }}
</button>
</Button>
</template>
<template v-else-if="fieldConfig.type === 'content'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
@ -277,7 +268,9 @@ const resetField = (fieldId: string) => {
:rows="3"
/>
</template>
<!-- TODO: Style Attachment Input -->
<template v-else-if="fieldConfig.type === 'attachment'">
<Spacer />
<attachment-input
:id="fieldConfig.id"
v-model="values[fieldConfig.id]"
@ -290,53 +283,56 @@ const resetField = (fieldId: string) => {
</attachment-input>
</template>
<template v-else-if="fieldConfig.type === 'tags'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<tags-selector
<Spacer/>
<Pills
:id="fieldConfig.id"
:label="fieldConfig.label"
ref="tags"
v-model="values[fieldConfig.id]"
required="fieldConfig.required"
/>
<button
>
<Button
class="ui tiny basic left floated button"
icon="bi-x"
form="noop"
@click.prevent="values[fieldConfig.id] = []"
>
<i class="x icon" />
{{ t('components.library.EditForm.button.clear') }}
</button>
</Button>
</Pills>
</template>
<div v-if="fieldValuesChanged(fieldConfig.id)">
<button
class="ui tiny basic right floated reset button"
<Button
tiny
alignSelf="end"
icon="bi-arrow-counterclockwise"
form="noop"
@click.prevent="resetField(fieldConfig.id)"
>
<i class="undo icon" />
{{ t('components.library.EditForm.button.reset') }}
</button>
</Button>
</div>
</div>
</template>
<div class="field">
<label for="summary">{{ t('components.library.EditForm.label.summary') }}</label>
<textarea
id="change-summary"
v-model="summary"
name="change-summary"
rows="3"
:placeholder="labels.summaryPlaceholder"
/>
</div>
<router-link
<Spacer/>
<Textarea
id="change-summary"
v-model="summary"
name="change-summary"
rows="3"
:label="t('components.library.EditForm.label.summary')"
:placeholder="labels.summaryPlaceholder"
/>
<Button
v-if="objectType === 'track'"
class="ui left floated button"
:to="{name: 'library.tracks.detail', params: {id: object.id }}"
>
{{ t('components.library.EditForm.button.cancel') }}
</router-link>
<button
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
</Button>
<Button
:class="['ui', 'right', 'floated', 'success', 'button']"
:isLoading="isLoading"
primary
type="submit"
:disabled="isLoading || !mutationPayload"
>
@ -346,7 +342,7 @@ const resetField = (fieldId: string) => {
<span v-else>
{{ t('components.library.EditForm.button.suggest') }}
</span>
</button>
</form>
</Button>
</Layout>
</div>
</template>

View File

@ -5,6 +5,9 @@ import { ref, watchEffect } from 'vue'
import axios from 'axios'
import EditCard from '~/components/library/EditCard.vue'
import Layout from '~/components/ui/Layout.vue'
import Button from '~/components/ui/Button.vue'
import Loader from '~/components/ui/Loader.vue'
interface Props {
url: string
@ -45,37 +48,24 @@ watchEffect(() => fetchData())
</script>
<template>
<div class="wrapper">
<h3 class="ui header">
<slot />
</h3>
<slot
v-if="!isLoading && objects.length === 0"
name="empty-state"
/>
<button
<h3>
<slot />
</h3>
<slot
v-if="!isLoading && objects.length === 0"
name="empty-state"
/>
<Layout grid>
<Button
v-if="nextPage || previousPage"
:disabled="!previousPage"
:class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"
round
alignSelf="center"
icon="bi-chevron-left"
@click="fetchData(previousPage)"
>
<i :class="['ui', 'angle left', 'icon']" />
</button>
<button
v-if="nextPage || previousPage"
:disabled="!nextPage"
:class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<i :class="['ui', 'angle right', 'icon']" />
</button>
<div class="ui hidden divider" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
</Button>
<Loader v-if="isLoading" />
<edit-card
v-for="obj in objects"
:key="obj.uuid"
@ -84,5 +74,14 @@ watchEffect(() => fetchData())
@updated="fetchData(url)"
@deleted="fetchData(url)"
/>
</div>
<Button
v-if="nextPage || previousPage"
:disabled="!nextPage"
alignSelf="center"
round
icon="bi-chevron-right"
@click="fetchData(nextPage)"
>
</Button>
</Layout>
</template>

View File

@ -9,6 +9,11 @@ import { useStore } from '~/store'
import axios from 'axios'
import RadioButton from '~/components/radios/Button.vue'
import Card from '~/components/ui/Card.vue'
import OptionsButton from '~/components/ui/button/Options.vue'
import Popover from '~/components/ui/Popover.vue'
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
import Spacer from '~/components/ui/Spacer.vue'
import useErrorHandler from '~/composables/useErrorHandler'
import useReport from '~/composables/moderation/useReport'
@ -142,58 +147,63 @@ watch(showScan, (shouldShow) => {
stopFetching()
})
const isOpen = ref(false)
</script>
<template>
<div class="ui card">
<Card
:title="library.name"
>
<div class="content">
<h4 class="header">
<router-link :to="{name: 'library.detail', params: {id: library.uuid}}">
{{ library.name }}
</router-link>
<div
v-dropdown
class="ui right floated dropdown"
>
<i class="ellipsis vertical large icon nomargin" />
<div class="menu">
<button
<Popover v-model:open="isOpen">
<template #default="{ toggleOpen }">
<OptionsButton
@click="toggleOpen"
/>
</template>
<template #items>
<PopoverItem
v-for="obj in getReportableObjects({library, account: library.actor})"
:key="obj.target.type + obj.target.id"
class="item basic"
@click.stop.prevent="report(obj)"
>
<i class="share icon" /> {{ obj.label }}
</button>
</div>
</div>
<i class="bi bi-share" /> {{ obj.label }}
</PopoverItem>
</template>
</Popover>
<span
v-if="library.privacy_level === 'me'"
class="right floated"
:data-tooltip="labels.tooltips.me"
>
<i class="small lock icon" />
<i class="bi bi-lock" />
</span>
<span
v-else-if="library.privacy_level === 'everyone'"
class="right floated"
:data-tooltip="labels.tooltips.everyone"
>
<i class="small globe icon" />
<i class="bi bi-globe" />
</span>
</h4>
<div class="meta">
<span>
<i class="small outline clock icon" />
<i class="bi bi-clock" />
<human-date :date="library.creation_date" />
</span>
</div>
<div class="description">
{{ library.description }}
<div class="ui hidden divider" />
</div>
<Spacer :size="8" />
<div class="meta">
<i class="music icon" />
<i class="bi bi-music-note" />
{{ t('views.content.remote.Card.meta.tracks', library.uploads_count) }}
</div>
<div
@ -201,7 +211,7 @@ watch(showScan, (shouldShow) => {
class="meta"
>
<template v-if="latestScan.status === 'pending'">
<i class="hourglass icon" />
<i class="bi bi-hourglass" />
{{ t('views.content.remote.Card.label.scanPending') }}
</template>
<template v-if="latestScan.status === 'scanning'">
@ -209,15 +219,15 @@ watch(showScan, (shouldShow) => {
{{ t('views.content.remote.Card.label.scanProgress', {progress: scanProgress}) }}
</template>
<template v-else-if="latestScan.status === 'errored'">
<i class="dangerdownload icon" />
<i class="bi bi-exclamation-triangle" />
{{ t('views.content.remote.Card.label.scanFailure') }}
</template>
<template v-else-if="latestScan.status === 'finished' && latestScan.errored_files === 0">
<i class="success download icon" />
<i class="bi bi-check-circle" />
{{ t('views.content.remote.Card.label.scanSuccess') }}
</template>
<template v-else-if="latestScan.status === 'finished' && latestScan.errored_files > 0">
<i class="warning download icon" />
<i class="bi bi-exclamation-circle" />
{{ t('views.content.remote.Card.label.scanPartialSuccess') }}
</template>
<a
@ -228,11 +238,11 @@ watch(showScan, (shouldShow) => {
{{ t('views.content.remote.Card.link.scanDetails') }}
<i
v-if="showScan"
class="angle down icon"
class="bi bi-chevron-down"
/>
<i
v-else
class="angle right icon"
class="bi bi-chevron-right"
/>
</a>
<div v-if="showScan">
@ -251,16 +261,19 @@ watch(showScan, (shouldShow) => {
class="right floated link"
@click.prevent="launchScan"
>
{{ t('views.content.remote.Card.link.scan') }}<i class="paper plane icon" />
{{ t('views.content.remote.Card.link.scan') }}
<i class="bi bi-send" />
</a>
</div>
</div>
<Spacer :size="8" />
<div class="extra content">
<actor-link
style="color: var(--link-color)"
:actor="library.actor"
/>
</div>
<Spacer :size="8" />
<div
v-if="displayCopyFid"
class="extra content"
@ -334,5 +347,5 @@ watch(showScan, (shouldShow) => {
</template>
</template>
</div>
</div>
</Card>
</template>