Lint the frontend code

This commit is contained in:
Georg Krause 2021-12-06 11:35:20 +01:00
parent 869fc20536
commit 8ee9a536e1
No known key found for this signature in database
GPG Key ID: FD479B9A4D48E632
254 changed files with 19510 additions and 10099 deletions

View File

@ -136,10 +136,7 @@ eslint:
- cd front
- yarn install
script:
# We search for all files ending with .vue or .js in src which changed in relation to develop
# and lint them. This way we focus on some errors instead of checking the hole repository
- export changedFiles=$(git diff --relative --name-only --diff-filter=d origin/develop -- src/ | grep -E "\.(vue|js)$")
- yarn run eslint --quiet -f table $(echo $changedFiles | tr '\n' ' ')
- yarn lint
cache:
key: "$CI_PROJECT_ID__eslint_npm_cache"
paths:

View File

@ -20,5 +20,7 @@ module.exports = {
'vue'
],
rules: {
"vue/no-v-html": "off", // TODO: tackle this properly
"vue/no-use-v-if-with-v-for": "off"
}
}

View File

@ -8,7 +8,7 @@
"serve": "[ ! -d src/translations ] && npm run i18n-compile; vue-cli-service serve --port ${VUE_PORT:-8080} --host ${VUE_HOST:-0.0.0.0}",
"build": "scripts/i18n-compile.sh && vue-cli-service build",
"test:unit": "vue-cli-service test:unit --reporter mocha-junit-reporter",
"lint": "eslint $(git status --porcelain --untracked-files=no | grep -E '(A|M) ' | cut -d' ' -f3 | sed s_front/__ | grep -E '.(js|vue)$')",
"lint": "eslint --ext .js,.vue src",
"i18n-compile": "scripts/i18n-compile.sh",
"i18n-extract": "scripts/i18n-extract.sh",
"fix-fomantic-css": "scripts/fix-fomantic-css.sh",

View File

@ -2,57 +2,110 @@
<template>
<main :class="[theme]">
<!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg -->
<svg aria-hidden="true" style="display: none" xmlns="http://www.w3.org/2000/svg">
<symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z"/></symbol>
<symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"/></symbol>
<symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"/></symbol>
<symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z"/></symbol>
<symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol>
<symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z"/></symbol>
<symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z"/><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z"/></symbol>
<symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"/></symbol>
<symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z"/></symbol>
<symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z"/></symbol>
<symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol>
<symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z"/><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol>
<svg
aria-hidden="true"
style="display: none"
xmlns="http://www.w3.org/2000/svg"
>
<symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z" /></symbol>
<symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z" /></symbol>
<symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z" /></symbol>
<symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z" /></symbol>
<symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol>
<symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z" /></symbol>
<symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" /><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z" /></symbol>
<symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z" /></symbol>
<symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z" /></symbol>
<symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" /></symbol>
<symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z" /></symbol>
<symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z" /><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z" /></symbol>
<!-- those ones are from fork-awesome -->
<symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z"/></symbol>
<symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z"/></symbol>
<symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z" /></symbol>
<symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z" /></symbol>
</svg>
<article>
<aside class="cover main" v-if="currentTrack">
<img height="120" v-if="currentTrack.cover" :src="currentTrack.cover" alt="Cover" />
<img height="120" v-else src="./assets/embed/default-cover.jpeg" alt="Cover" />
<aside
v-if="currentTrack"
class="cover main"
>
<img
v-if="currentTrack.cover"
height="120"
:src="currentTrack.cover"
alt="Cover"
>
<img
v-else
height="120"
src="./assets/embed/default-cover.jpeg"
alt="Cover"
>
</aside>
<div class="content" aria-label="Track information">
<div
class="content"
aria-label="Track information"
>
<header v-if="currentTrack">
<h3><a :href="fullUrl('/library/tracks/' + currentTrack.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.title }}</a></h3>
<a :href="fullUrl('/library/artists/' + currentTrack.artist.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.artist.name }}</a>
<h3>
<a
:href="fullUrl('/library/tracks/' + currentTrack.id)"
target="_blank"
rel="noopener noreferrer"
>{{ currentTrack.title }}</a>
</h3>
<a
:href="fullUrl('/library/artists/' + currentTrack.artist.id)"
target="_blank"
rel="noopener noreferrer"
>{{ currentTrack.artist.name }}</a>
</header>
<section v-if="!isLoading" class="controls" aria-label="Audio player">
<section
v-if="!isLoading"
class="controls"
aria-label="Audio player"
>
<template v-if="currentTrack && currentTrack.sources.length > 0">
<div class="queue-controls plyr--audio" v-if="tracks.length > 1">
<div
v-if="tracks.length > 1"
class="queue-controls plyr--audio"
>
<div class="plyr__controls">
<button
type="button"
class="plyr__control"
aria-label="Play previous track"
@focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)"
@click="previous()"
type="button"
class="plyr__control"
aria-label="Play previous track">
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
<use xlink:href="#plyr-step-backward"></use>
>
<svg
class="icon--not-pressed"
role="presentation"
focusable="false"
viewBox="0 0 1100 1650"
width="80"
height="80"
>
<use xlink:href="#plyr-step-backward" />
</svg>
</button>
<button
type="button"
class="plyr__control"
aria-label="Play next track"
@click="next()"
@focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)"
type="button"
class="plyr__control"
aria-label="Play next track">
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
<use xlink:href="#plyr-step-forward"></use>
>
<svg
class="icon--not-pressed"
role="presentation"
focusable="false"
viewBox="0 0 1100 1650"
width="80"
height="80"
>
<use xlink:href="#plyr-step-forward" />
</svg>
</button>
</div>
@ -62,51 +115,122 @@
:key="currentIndex"
ref="player"
class="player"
:options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration, autoplay}">
:options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration, autoplay}"
>
<audio preload="none">
<source v-for="source in currentTrack.sources" :src="source.src" :type="source.type"/>
<source
v-for="(source, key) in currentTrack.sources"
:key="key"
:src="source.src"
:type="source.type"
>
</audio>
</vue-plyr>
</template>
<div v-else class="player">
<span v-if="error === 'invalid_type'" class="error">Widget improperly configured (bad resource type {{ type }}).</span>
<span v-else-if="error === 'invalid_id'" class="error">Widget improperly configured (missing resource id).</span>
<span v-else-if="error === 'server_not_found'" class="error">Track not found.</span>
<span v-else-if="error === 'server_requires_auth'" class="error">You need to login to access this resource.</span>
<span v-else-if="error === 'server_error'" class="error">A server error occurred.</span>
<span v-else-if="error === 'server_error'" class="error">An unknown error occurred while loading track data from server.</span>
<span v-else-if="currentTrack && currentTrack.sources.length === 0" class="error">This track is unavailable.</span>
<span v-else class="error">An unknown error occurred while loading track data.</span>
<div
v-else
class="player"
>
<span
v-if="error === 'invalid_type'"
class="error"
>Widget improperly configured (bad resource type {{ type }}).</span>
<span
v-else-if="error === 'invalid_id'"
class="error"
>Widget improperly configured (missing resource id).</span>
<span
v-else-if="error === 'server_not_found'"
class="error"
>Track not found.</span>
<span
v-else-if="error === 'server_requires_auth'"
class="error"
>You need to login to access this resource.</span>
<span
v-else-if="error === 'server_error'"
class="error"
>An unknown error occurred while loading track data from server.</span>
<span
v-else-if="currentTrack && currentTrack.sources.length === 0"
class="error"
>This track is unavailable.</span>
<span
v-else
class="error"
>An unknown error occurred while loading track data.</span>
</div>
<a title="Funkwhale" href="https://funkwhale.audio" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
<logo :fill="currentTheme.textColor" class="logo"></logo>
<a
title="Funkwhale"
href="https://funkwhale.audio"
target="_blank"
rel="noopener noreferrer"
class="logo-wrapper"
>
<logo
:fill="currentTheme.textColor"
class="logo"
/>
</a>
</section>
</div>
</article>
<div v-if="tracks.length > 1" class="queue-wrapper" id="queue">
<div
v-if="tracks.length > 1"
id="queue"
class="queue-wrapper"
>
<table class="queue">
<tbody>
<tr
:id="'queue-item-' + index"
role="button"
v-for="(track, index) in tracks"
v-if="track.sources.length > 0"
:id="'queue-item-' + index"
:key="index"
role="button"
:class="[{active: index === currentIndex}]"
@click="play(index)"
@keyup.enter="play(index)"
v-for="(track, index) in tracks">
<td class="position-cell" width="40">
>
<td
class="position-cell"
width="40"
>
<span class="position">
{{ index + 1 }}
</span>
</td>
<td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td>
<td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td>
<td class="album">
<div class="ellipsis" v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
<td
class="title"
:title="track.title"
>
<div
colspan="2"
class="ellipsis"
>
{{ track.title }}
</div>
</td>
<td
class="artist"
:title="track.artist.name"
>
<div class="ellipsis">
{{ track.artist.name }}
</div>
</td>
<td class="album">
<div
v-if="track.album"
class="ellipsis"
:title="track.album.title"
>
{{ track.album.title }}
</div>
</td>
<td width="50">
{{ time.durationFormatted(track.sources[0].duration) }}
</td>
<td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
</tr>
</tbody>
</table>
@ -116,26 +240,24 @@
<script>
import axios from 'axios'
import Logo from "@/components/Logo"
import Logo from '@/components/Logo'
import url from '@/utils/url'
import time from '@/utils/time'
function getURLParams () {
var urlParams
var match,
pl = /\+/g, // Regex for replacing addition symbol with a space
search = /([^&=]+)=?([^&]*)/g,
decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); },
query = window.location.search.substring(1);
let match
const pl = /\+/g // Regex for replacing addition symbol with a space
const urlParams = {}
const search = /([^&=]+)=?([^&]*)/g
const decode = function (s) { return decodeURIComponent(s.replace(pl, ' ')) }
const query = window.location.search.substring(1)
urlParams = {};
while (match = search.exec(query))
urlParams[decode(match[1])] = decode(match[2]);
while (match === search.exec(query)) { urlParams[decode(match[1])] = decode(match[2]) }
return urlParams
}
export default {
name: 'app',
components: {Logo},
name: 'App',
components: { Logo },
data () {
return {
time,
@ -152,13 +274,59 @@ export default {
currentIndex: -1,
themes: {
dark: {
textColor: 'white',
textColor: 'white'
}
}
}
},
computed: {
currentTrack () {
if (this.tracks.length === 0) {
return null
}
return this.tracks[this.currentIndex]
},
currentTheme () {
return this.themes[this.theme]
},
controls () {
return [
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'mute', // Toggle mute
'volume' // Volume control
]
},
hasPrevious () {
return this.currentIndex > 0
},
hasNext () {
return this.currentIndex < this.tracks.length - 1
}
},
watch: {
currentIndex (v) {
// we bind player events
const self = this
this.$nextTick(() => {
self.bindEvents()
if (self.tracks.length > 0) {
const el = document.getElementById(`queue-item-${v}`)
if (!el) {
return
}
const topPos = el.offsetTop
document.getElementById('queue').scrollTop = topPos - 10
}
})
},
tracks () {
this.currentIndex = 0
}
},
created () {
let params = getURLParams()
const params = getURLParams()
this.baseUrl = params.b || ''
this.type = params.type
if (this.supportedTypes.indexOf(this.type) === -1) {
@ -172,44 +340,18 @@ export default {
this.isLoading = false
return
}
if (!!params.instance) {
if (params.instance) {
this.baseUrl = params.instance
}
this.autoplay = params.autoplay != undefined || params.auto_play != undefined
this.autoplay = params.autoplay !== undefined || params.auto_play !== undefined
this.fetch(this.type, this.id)
},
mounted () {
var parser = document.createElement('a')
const parser = document.createElement('a')
parser.href = this.baseUrl
this.url = parser
},
computed: {
currentTrack () {
if (this.tracks.length === 0) {
return null
}
return this.tracks[this.currentIndex]
},
currentTheme () {
return this.themes[this.theme]
},
controls () {
return [
'play', // Play/pause playback
'progress', // The progress bar and scrubber for playback and buffering
'current-time', // The current time of playback
'mute', // Toggle mute
'volume', // Volume control
]
},
hasPrevious () {
return this.currentIndex > 0
},
hasNext () {
return this.currentIndex < this.tracks.length - 1
},
},
methods: {
next () {
if (this.hasNext) {
@ -221,11 +363,11 @@ export default {
this.play(this.currentIndex - 1)
}
},
setControlFocus(event, enable) {
setControlFocus (event, enable) {
if (enable) {
event.target.classList.add("plyr__tab-focus");
event.target.classList.add('plyr__tab-focus')
} else {
event.target.classList.remove("plyr__tab-focus");
event.target.classList.remove('plyr__tab-focus')
}
},
fetch (type, id) {
@ -233,13 +375,13 @@ export default {
this.fetchTrack(id)
}
if (type === 'album') {
this.fetchTracks({album: id, playable: true, ordering: "disc_number,position"})
this.fetchTracks({ album: id, playable: true, ordering: 'disc_number,position' })
}
if (type === 'channel') {
this.fetchTracks({channel: id, playable: true, include_channels: 'true', ordering: "-creation_date"})
this.fetchTracks({ channel: id, playable: true, include_channels: 'true', ordering: '-creation_date' })
}
if (type === 'artist') {
this.fetchTracks({artist: id, playable: true, include_channels: 'true', ordering: "-album__release_date,disc_number,position"})
this.fetchTracks({ artist: id, playable: true, include_channels: 'true', ordering: '-album__release_date,disc_number,position' })
}
if (type === 'playlist') {
this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)
@ -247,67 +389,61 @@ export default {
},
play (index) {
this.currentIndex = index
let self = this
const self = this
this.$nextTick(() => {
self.$refs.player.player.play()
})
},
fetchTrack (id) {
let self = this
let url = `${this.baseUrl}/api/v1/tracks/${id}/`
const self = this
const url = `${this.baseUrl}/api/v1/tracks/${id}/`
axios.get(url).then(response => {
self.tracks = self.parseTracks([response.data])
self.isLoading = false;
self.isLoading = false
}).catch(error => {
if (error.response) {
if (error.response.status === 404) {
self.error = 'server_not_found'
}
else if (error.response.status === 403) {
} else if (error.response.status === 403) {
self.error = 'server_requires_auth'
}
else if (error.response.status === 500) {
} else if (error.response.status === 500) {
self.error = 'server_error'
}
else {
} else {
self.error = 'server_unknown_error'
}
} else {
self.error = 'server_unknown_error'
}
self.isLoading = false;
self.isLoading = false
})
},
fetchTracks (filters, path) {
path = path || "/api/v1/tracks/"
filters.include_channels = "true"
let self = this
let url = `${this.baseUrl}${path}`
axios.get(url, {params: filters}).then(response => {
path = path || '/api/v1/tracks/'
filters.include_channels = 'true'
const self = this
const url = `${this.baseUrl}${path}`
axios.get(url, { params: filters }).then(response => {
self.tracks = self.parseTracks(response.data.results)
self.isLoading = false;
self.isLoading = false
}).catch(error => {
if (error.response) {
if (error.response.status === 404) {
self.error = 'server_not_found'
}
else if (error.response.status === 403) {
} else if (error.response.status === 403) {
self.error = 'server_requires_auth'
}
else if (error.response.status === 500) {
} else if (error.response.status === 500) {
self.error = 'server_error'
}
else {
} else {
self.error = 'server_unknown_error'
}
} else {
self.error = 'server_unknown_error'
}
self.isLoading = false;
self.isLoading = false
})
},
parseTracks (tracks) {
let self = this
const self = this
if (this.type === 'playlist') {
tracks = tracks.map((t) => {
return t.track
@ -325,7 +461,7 @@ export default {
})
},
bindEvents () {
let self = this
const self = this
this.$refs.player.player.on('ended', () => {
self.next()
})
@ -336,17 +472,17 @@ export default {
}
return path
},
getCover(albumCover) {
getCover (albumCover) {
if (albumCover) {
return albumCover.urls.medium_square_crop
}
},
getSources (uploads) {
let self = this;
let a = document.createElement('audio')
let allowed = ['probably', 'maybe']
let sources = uploads.filter(u => {
let canPlay = a.canPlayType(u.mimetype)
const self = this
const a = document.createElement('audio')
const allowed = ['probably', 'maybe']
const sources = uploads.filter(u => {
const canPlay = a.canPlayType(u.mimetype)
return allowed.indexOf(canPlay) > -1
}).map(u => {
return {
@ -371,26 +507,6 @@ export default {
}
return sources
}
},
watch: {
currentIndex (v) {
// we bind player events
let self = this
this.$nextTick(() => {
self.bindEvents()
if (self.tracks.length > 0) {
let el = document.getElementById(`queue-item-${v}`);
if (!el) {
return
}
var topPos = el.offsetTop;
document.getElementById('queue').scrollTop = topPos-10;
}
})
},
tracks () {
this.currentIndex = 0
}
}
}
</script>

View File

@ -1,4 +1,4 @@
var Album = {
const Album = {
clean (album) {
// we manually rebind the album and artist to each child track
album.tracks = album.tracks.map((track) => {
@ -8,7 +8,7 @@ var Album = {
return album
}
}
var Artist = {
const Artist = {
clean (artist) {
// clean data as given by the API
artist.albums = artist.albums.map((album) => {

View File

@ -1,25 +1,25 @@
const DYNAMIC_RANGE = 40 // dB
function toLinearVolumeScale(v) {
if (v <= 0.0) {
return 0.0
}
function toLinearVolumeScale (v) {
if (v <= 0.0) {
return 0.0
}
// (1.0; 0.0) -> (0; -DYNAMIC_RANGE) dB
let dB = (v-1)*DYNAMIC_RANGE
// (1.0; 0.0) -> (0; -DYNAMIC_RANGE) dB
const dB = (v - 1) * DYNAMIC_RANGE
return Math.pow(10, dB / 20)
return Math.pow(10, dB / 20)
}
function toLogarithmicVolumeScale(v) {
if (v <= 0.0) {
return 0.0
}
function toLogarithmicVolumeScale (v) {
if (v <= 0.0) {
return 0.0
}
let dB = 20 * Math.log10(v)
const dB = 20 * Math.log10(v)
// (0; -DYNAMIC_RANGE) [dB] -> (1.0; 0.0)
return 1 - (dB / -DYNAMIC_RANGE)
// (0; -DYNAMIC_RANGE) [dB] -> (1.0; 0.0)
return 1 - (dB / -DYNAMIC_RANGE)
}
exports.toLinearVolumeScale = toLinearVolumeScale

View File

@ -1,77 +1,208 @@
<template>
<footer id="footer" role="contentinfo" class="ui vertical footer segment" aria-labelledby="footer-label">
<h1 id="footer-label" class="visually-hidden">
<translate translate-context="*/*/*">Application footer</translate>
<footer
id="footer"
role="contentinfo"
class="ui vertical footer segment"
aria-labelledby="footer-label"
>
<h1
id="footer-label"
class="visually-hidden"
>
<translate translate-context="*/*/*">
Application footer
</translate>
</h1>
<div class="ui container">
<div class="ui stackable equal height stackable grid">
<section class="four wide column">
<h4 v-if="podName" class="ui header ellipsis">
<span v-translate="{instanceName: podName}" translate-context="Footer/About/Title">About %{instanceName}</span>
<h4
v-if="podName"
class="ui header ellipsis"
>
<span
v-translate="{instanceName: podName}"
translate-context="Footer/About/Title"
>About %{instanceName}</span>
</h4>
<h4 v-else class="ui header ellipsis">
<span v-translate="{instanceUrl: instanceHostname}" translate-context="Footer/About/Title">About %{instanceUrl}</span>
<h4
v-else
class="ui header ellipsis"
>
<span
v-translate="{instanceUrl: instanceHostname}"
translate-context="Footer/About/Title"
>About %{instanceUrl}</span>
</h4>
<div class="ui list">
<router-link v-if="this.$route.path != '/about'" class="link item" to="/about">
<translate translate-context="Footer/About/List item.Link">About</translate>
<router-link
v-if="$route.path != '/about'"
class="link item"
to="/about"
>
<translate translate-context="Footer/About/List item.Link">
About
</translate>
</router-link>
<router-link v-else-if="this.$route.path == '/about' && $store.state.auth.authenticated" class="link item" to="/library">
<translate translate-context="Footer/*/List item.Link">Go to Library</translate>
<router-link
v-else-if="$route.path == '/about' && $store.state.auth.authenticated"
class="link item"
to="/library"
>
<translate translate-context="Footer/*/List item.Link">
Go to Library
</translate>
</router-link>
<router-link v-else class="link item" to="/">
<translate translate-context="Footer/*/List item.Link">Home Page</translate>
<router-link
v-else
class="link item"
to="/"
>
<translate translate-context="Footer/*/List item.Link">
Home Page
</translate>
</router-link>
<a v-if="version" class="link item" href="https://docs.funkwhale.audio/changelog.html" target="_blank">
<translate translate-context="Footer/*/List item" :translate-params="{version: version}" >Version %{version}</translate>
</a>
<a role="button" href="" class="link item" @click.prevent="$emit('show:set-instance-modal')" >
<a
v-if="version"
class="link item"
href="https://docs.funkwhale.audio/changelog.html"
target="_blank"
>
<translate
translate-context="Footer/*/List item"
:translate-params="{version: version}"
>Version %{version}</translate>
</a>
<a
role="button"
href=""
class="link item"
@click.prevent="$emit('show:set-instance-modal')"
>
<translate translate-context="Footer/*/List item.Link">Use another instance</translate>
</a>
</div>
<div class="ui form">
<div class="ui field">
<label for="language-select"><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change language</translate></label>
<select id="language-select" class="ui dropdown" :value="$language.current" @change="$store.dispatch('ui/currentLanguage', $event.target.value)">
<option v-for="(language, key) in $language.available" :key="key" :value="key">{{ language }}</option>
<select
id="language-select"
class="ui dropdown"
:value="$language.current"
@change="$store.dispatch('ui/currentLanguage', $event.target.value)"
>
<option
v-for="(language, key) in $language.available"
:key="key"
:value="key"
>
{{ language }}
</option>
</select>
</div>
</div>
</section>
<section class="four wide column">
<h4 v-translate class="ui header" translate-context="Footer/*/Title">Using Funkwhale</h4>
<h4
v-translate
class="ui header"
translate-context="Footer/*/Title"
>
Using Funkwhale
</h4>
<div class="ui list">
<a href="https://docs.funkwhale.audio" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link/Short, Noun">Documentation</translate></a>
<a href="https://funkwhale.audio/apps" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Mobile and desktop apps</translate></a>
<a hrelf="" class="link item" @click.prevent="$emit('show:shortcuts-modal')"><translate translate-context="*/*/*/Noun">Keyboard shortcuts</translate></a>
<a
href="https://docs.funkwhale.audio"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link/Short, Noun">Documentation</translate></a>
<a
href="https://funkwhale.audio/apps"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Mobile and desktop apps</translate></a>
<a
hrelf=""
class="link item"
@click.prevent="$emit('show:shortcuts-modal')"
><translate translate-context="*/*/*/Noun">Keyboard shortcuts</translate></a>
</div>
<div class="ui form">
<div class="ui field">
<label for="theme-select"><translate translate-context="Footer/Settings/Dropdown.Label/Short, Verb">Change theme</translate></label>
<select id="theme-select" class="ui dropdown" :value="$store.state.ui.theme" @change="$store.dispatch('ui/theme', $event.target.value)">
<option v-for="theme in themes" :key="theme.key" :value="theme.key">{{ theme.name }}</option>
<select
id="theme-select"
class="ui dropdown"
:value="$store.state.ui.theme"
@change="$store.dispatch('ui/theme', $event.target.value)"
>
<option
v-for="theme in themes"
:key="theme.key"
:value="theme.key"
>
{{ theme.name }}
</option>
</select>
</div>
</div>
</section>
<section class="four wide column">
<h4 v-translate translate-context="Footer/*/Link" class="ui header">Getting help</h4>
<h4
v-translate
translate-context="Footer/*/Link"
class="ui header"
>
Getting help
</h4>
<div class="ui list">
<a href="https://forum.funkwhale.audio/" class="link item" target="_blank"><translate translate-context="Footer/*/Listitem.Link">Support forum</translate></a>
<a href="https://matrix.to/#/#funkwhale-troubleshooting:matrix.org" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Chat room</translate></a>
<a href="https://dev.funkwhale.audio/funkwhale/funkwhale/issues" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Issue tracker</translate></a>
<a
href="https://forum.funkwhale.audio/"
class="link item"
target="_blank"
><translate translate-context="Footer/*/Listitem.Link">Support forum</translate></a>
<a
href="https://matrix.to/#/#funkwhale-troubleshooting:matrix.org"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Chat room</translate></a>
<a
href="https://dev.funkwhale.audio/funkwhale/funkwhale/issues"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Issue tracker</translate></a>
</div>
</section>
<section class="four wide column">
<h4 v-translate class="ui header" translate-context="Footer/*/Title/Short">About Funkwhale</h4>
<h4
v-translate
class="ui header"
translate-context="Footer/*/Title/Short"
>
About Funkwhale
</h4>
<div class="ui list">
<a href="https://funkwhale.audio" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Official website</translate></a>
<a href="https://contribute.funkwhale.audio" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Contribute</translate></a>
<a href="https://dev.funkwhale.audio/funkwhale/funkwhale" class="link item" target="_blank"><translate translate-context="Footer/*/List item.Link">Source code</translate></a>
<a
href="https://funkwhale.audio"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Official website</translate></a>
<a
href="https://contribute.funkwhale.audio"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Contribute</translate></a>
<a
href="https://dev.funkwhale.audio/funkwhale/funkwhale"
class="link item"
target="_blank"
><translate translate-context="Footer/*/List item.Link">Source code</translate></a>
</div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<p>
<translate translate-context="Footer/*/List item.Link">The Funkwhale logo was kindly designed and provided by Francis Gading.</translate>
<translate translate-context="Footer/*/List item.Link">
The Funkwhale logo was kindly designed and provided by Francis Gading.
</translate>
</p>
</section>
</div>
@ -80,24 +211,22 @@
</template>
<script>
import Vue from "vue"
import { mapState } from "vuex"
import axios from 'axios'
import { mapState } from 'vuex'
import _ from '@/lodash'
export default {
props: ["version"],
props: { version: { type: String, required: true } },
computed: {
...mapState({
messages: state => state.ui.messages,
nodeinfo: state => state.instance.nodeinfo,
nodeinfo: state => state.instance.nodeinfo
}),
podName() {
podName () {
return _.get(this.nodeinfo, 'metadata.nodeName')
},
instanceHostname() {
let url = this.$store.state.instance.instanceUrl
let parser = document.createElement("a")
instanceHostname () {
const url = this.$store.state.instance.instanceUrl
const parser = document.createElement('a')
parser.href = url
return parser.hostname
},

View File

@ -1,15 +1,25 @@
<template>
<main class="main pusher page-home" v-title="labels.title">
<section :class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle">
<main
v-title="labels.title"
class="main pusher page-home"
>
<section
:class="['ui', 'head', {'with-background': banner}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
>
<div class="segment-content">
<h1 class="ui center aligned large header">
<span
v-translate="{podName: podName}"
translate-context="Content/Home/Header"
:translate-params="{podName: podName}">
:translate-params="{podName: podName}"
>
Welcome to %{ podName }!
</span>
<div v-if="shortDescription" class="sub header">
<div
v-if="shortDescription"
class="sub header"
>
{{ shortDescription }}
</div>
</h1>
@ -19,31 +29,61 @@
<div class="ui stackable grid">
<div class="ten wide column">
<h2 class="header">
<translate translate-context="Content/Home/Header">About this Funkwhale pod</translate>
<translate translate-context="Content/Home/Header">
About this Funkwhale pod
</translate>
</h2>
<div class="ui raised segment" id="pod">
<div
id="pod"
class="ui raised segment"
>
<div class="ui stackable grid">
<div class="eight wide column">
<p v-if="!truncatedDescription">
<translate translate-context="Content/Home/Paragraph">No description available.</translate>
<translate translate-context="Content/Home/Paragraph">
No description available.
</translate>
</p>
<template v-if="truncatedDescription || rules">
<div v-if="truncatedDescription" v-html="truncatedDescription"></div>
<div v-if="truncatedDescription" class="ui hidden divider"></div>
<div
v-if="truncatedDescription"
v-html="truncatedDescription"
/>
<div
v-if="truncatedDescription"
class="ui hidden divider"
/>
<div class="ui relaxed list">
<div class="item" v-if="truncatedDescription">
<i class="arrow right icon"></i>
<div
v-if="truncatedDescription"
class="item"
>
<i class="arrow right icon" />
<div class="content">
<router-link class="ui link" :to="{name: 'about'}">
<translate translate-context="Content/Home/Link">Learn more</translate>
<router-link
class="ui link"
:to="{name: 'about'}"
>
<translate translate-context="Content/Home/Link">
Learn more
</translate>
</router-link>
</div>
</div>
<div class="item" v-if="rules">
<i class="book open icon"></i>
<div
v-if="rules"
class="item"
>
<i class="book open icon" />
<div class="content">
<router-link class="ui link" v-if="rules" :to="{name: 'about', hash: '#rules'}">
<translate translate-context="Content/Home/Link">Server rules</translate>
<router-link
v-if="rules"
class="ui link"
:to="{name: 'about', hash: '#rules'}"
>
<translate translate-context="Content/Home/Link">
Server rules
</translate>
</router-link>
</div>
</div>
@ -53,71 +93,130 @@
<div class="eight wide column">
<template v-if="stats">
<h3 class="sub header">
<translate translate-context="Content/Home/Header">Statistics</translate>
<translate translate-context="Content/Home/Header">
Statistics
</translate>
</h3>
<p>
<i class="user icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: stats.users.toLocaleString($store.state.ui.momentLocale) }" :translate-n="stats.users" translate-plural="%{ count } active users">%{ count } active user</translate>
<i class="user icon" /><translate
translate-context="Content/Home/Stat"
:translate-params="{count: stats.users.toLocaleString($store.state.ui.momentLocale) }"
:translate-n="stats.users"
translate-plural="%{ count } active users"
>
%{ count } active user
</translate>
</p>
<p>
<i class="music icon"></i><translate translate-context="Content/Home/Stat" :translate-params="{count: parseInt(stats.hours).toLocaleString($store.state.ui.momentLocale)}" :translate-n="parseInt(stats.hours)" translate-plural="%{ count } hours of music">%{ count } hour of music</translate>
<i class="music icon" /><translate
translate-context="Content/Home/Stat"
:translate-params="{count: parseInt(stats.hours).toLocaleString($store.state.ui.momentLocale)}"
:translate-n="parseInt(stats.hours)"
translate-plural="%{ count } hours of music"
>
%{ count } hour of music
</translate>
</p>
</template>
<template v-if="contactEmail">
<h3 class="sub header">
<translate translate-context="Content/Home/Header/Name">Contact</translate>
<translate translate-context="Content/Home/Header/Name">
Contact
</translate>
</h3>
<i class="at icon"></i>
<i class="at icon" />
<a :href="`mailto:${contactEmail}`">{{ contactEmail }}</a>
</template>
</div>
</div>
</div>
</div>
<div class="six wide column">
<img class="ui image" src="../assets/network.png" alt=""/>
<img
class="ui image"
src="../assets/network.png"
alt=""
>
</div>
</div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<div class="ui hidden divider" />
<div class="ui stackable grid">
<div class="four wide column">
<h3 class="header">
<translate translate-context="Footer/*/Title/Short">About Funkwhale</translate>
<translate translate-context="Footer/*/Title/Short">
About Funkwhale
</translate>
</h3>
<p v-translate translate-context="Content/Home/Paragraph">This pod runs Funkwhale, a community-driven project that lets you listen and share music and audio within a decentralized, open network.</p>
<p v-translate translate-context="Content/Home/Paragraph">Funkwhale is free and developed by a friendly community of volunteers.</p>
<a target="_blank" rel="noopener" href="https://funkwhale.audio">
<i class="external alternate icon"></i>
<p
v-translate
translate-context="Content/Home/Paragraph"
>
This pod runs Funkwhale, a community-driven project that lets you listen and share music and audio within a decentralized, open network.
</p>
<p
v-translate
translate-context="Content/Home/Paragraph"
>
Funkwhale is free and developed by a friendly community of volunteers.
</p>
<a
target="_blank"
rel="noopener"
href="https://funkwhale.audio"
>
<i class="external alternate icon" />
<translate translate-context="Content/Home/Link">Visit funkwhale.audio</translate>
</a>
</div>
<div class="four wide column">
<h3 class="header">
<translate translate-context="Head/Login/Title">Log In</translate>
<translate translate-context="Head/Login/Title">
Log In
</translate>
</h3>
<login-form button-classes="success" :show-signup="false"></login-form>
<div class="ui hidden clearing divider"></div>
<login-form
button-classes="success"
:show-signup="false"
/>
<div class="ui hidden clearing divider" />
</div>
<div class="four wide column">
<h3 class="header">
<translate translate-context="*/Signup/Title">Sign up</translate>
<translate translate-context="*/Signup/Title">
Sign up
</translate>
</h3>
<template v-if="openRegistrations">
<p>
<translate translate-context="Content/Home/Paragraph">Sign up now to keep track of your favorites, create playlists, discover new content and much more!</translate>
<translate translate-context="Content/Home/Paragraph">
Sign up now to keep track of your favorites, create playlists, discover new content and much more!
</translate>
</p>
<p v-if="defaultUploadQuota">
<translate translate-context="Content/Home/Paragraph" :translate-params="{quota: humanSize(defaultUploadQuota * 1000 * 1000)}">Users on this pod also get %{ quota } of free storage to upload their own content!</translate>
<translate
translate-context="Content/Home/Paragraph"
:translate-params="{quota: humanSize(defaultUploadQuota * 1000 * 1000)}"
>
Users on this pod also get %{ quota } of free storage to upload their own content!
</translate>
</p>
<signup-form button-classes="success" :show-login="false"></signup-form>
<signup-form
button-classes="success"
:show-login="false"
/>
</template>
<div v-else>
<p translate-context="Content/Home/Paragraph">Registrations are closed on this pod. You can signup on another pod using the link below.</p>
<a target="_blank" rel="noopener" href="https://funkwhale.audio/#get-started">
<i class="external alternate icon"></i>
<p translate-context="Content/Home/Paragraph">
Registrations are closed on this pod. You can signup on another pod using the link below.
</p>
<a
target="_blank"
rel="noopener"
href="https://funkwhale.audio/#get-started"
>
<i class="external alternate icon" />
<translate translate-context="Content/Home/Link">Find another pod</translate>
</a>
</div>
@ -125,39 +224,63 @@
<div class="four wide column">
<h3 class="header">
<translate translate-context="Content/Home/Header">Useful links</translate>
<translate translate-context="Content/Home/Header">
Useful links
</translate>
</h3>
<div class="ui relaxed list">
<div class="item">
<i class="headphones icon"></i>
<i class="headphones icon" />
<div class="content">
<router-link v-if="anonymousCanListen" class="header" to="/library">
<translate translate-context="Content/Home/Link">Browse public content</translate>
<router-link
v-if="anonymousCanListen"
class="header"
to="/library"
>
<translate translate-context="Content/Home/Link">
Browse public content
</translate>
</router-link>
<div class="description">
<translate translate-context="Content/Home/Link">Listen to public albums and playlists shared on this pod</translate>
<translate translate-context="Content/Home/Link">
Listen to public albums and playlists shared on this pod
</translate>
</div>
</div>
</div>
<div class="item">
<i class="mobile alternate icon"></i>
<i class="mobile alternate icon" />
<div class="content">
<a class="header" href="https://funkwhale.audio/apps" target="_blank" rel="noopener">
<a
class="header"
href="https://funkwhale.audio/apps"
target="_blank"
rel="noopener"
>
<translate translate-context="Content/Home/Link">Mobile apps</translate>
</a>
<div class="description">
<translate translate-context="Content/Home/Link">Use Funkwhale on other devices with our apps</translate>
<translate translate-context="Content/Home/Link">
Use Funkwhale on other devices with our apps
</translate>
</div>
</div>
</div>
<div class="item">
<i class="book icon"></i>
<i class="book icon" />
<div class="content">
<a class="header" href="https://docs.funkwhale.audio/users/index.html" target="_blank" rel="noopener">
<a
class="header"
href="https://docs.funkwhale.audio/users/index.html"
target="_blank"
rel="noopener"
>
<translate translate-context="Content/Home/Link">User guides</translate>
</a>
<div class="description">
<translate translate-context="Content/Home/Link">Discover everything you need to know about Funkwhale and its features</translate>
<translate translate-context="Content/Home/Link">
Discover everything you need to know about Funkwhale and its features
</translate>
</div>
</div>
</div>
@ -165,20 +288,37 @@
</div>
</div>
</section>
<section v-if="anonymousCanListen" class="ui vertical stripe segment">
<album-widget :filters="{playable: true, ordering: '-creation_date'}" :limit="10">
<template slot="title"><translate translate-context="Content/Home/Title">Recently added albums</translate></template>
<section
v-if="anonymousCanListen"
class="ui vertical stripe segment"
>
<album-widget
:filters="{playable: true, ordering: '-creation_date'}"
:limit="10"
>
<template slot="title">
<translate translate-context="Content/Home/Title">
Recently added albums
</translate>
</template>
<router-link to="/library">
<translate translate-context="Content/Home/Link">View more</translate>
<div class="ui hidden divider"></div>
<translate translate-context="Content/Home/Link">
View more
</translate>
<div class="ui hidden divider" />
</router-link>
</album-widget>
<div class="ui hidden section divider"></div>
<h3 class="ui header" >
<translate translate-context="*/*/*">New channels</translate>
<div class="ui hidden section divider" />
<h3 class="ui header">
<translate translate-context="*/*/*">
New channels
</translate>
</h3>
<channels-widget :show-modification-date="true" :limit="10" :filters="{ordering: '-creation_date', external: 'false'}"></channels-widget>
<channels-widget
:show-modification-date="true"
:limit="10"
:filters="{ordering: '-creation_date', external: 'false'}"
/>
</section>
</main>
</template>
@ -186,20 +326,20 @@
<script>
import $ from 'jquery'
import _ from '@/lodash'
import {mapState} from 'vuex'
import { mapState } from 'vuex'
import showdown from 'showdown'
import AlbumWidget from "@/components/audio/album/Widget"
import ChannelsWidget from "@/components/audio/ChannelsWidget"
import LoginForm from "@/components/auth/LoginForm"
import SignupForm from "@/components/auth/SignupForm"
import {humanSize } from '@/filters'
import AlbumWidget from '@/components/audio/album/Widget'
import ChannelsWidget from '@/components/audio/ChannelsWidget'
import LoginForm from '@/components/auth/LoginForm'
import SignupForm from '@/components/auth/SignupForm'
import { humanSize } from '@/filters'
export default {
components: {
AlbumWidget,
ChannelsWidget,
LoginForm,
SignupForm,
SignupForm
},
data () {
return {
@ -210,15 +350,15 @@ export default {
},
computed: {
...mapState({
nodeinfo: state => state.instance.nodeinfo,
nodeinfo: state => state.instance.nodeinfo
}),
labels() {
labels () {
return {
title: this.$pgettext('Head/Home/Title', "Home")
title: this.$pgettext('Head/Home/Title', 'Home')
}
},
podName() {
return _.get(this.nodeinfo, 'metadata.nodeName') || "Funkwhale"
podName () {
return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
},
banner () {
return _.get(this.nodeinfo, 'metadata.banner')
@ -236,12 +376,12 @@ export default {
if (!this.longDescription) {
return
}
let doc = this.markdown.makeHtml(this.longDescription)
let nodes = $.parseHTML(doc)
let excerptParts = []
const doc = this.markdown.makeHtml(this.longDescription)
const nodes = $.parseHTML(doc)
const excerptParts = []
let handled = 0
nodes.forEach((n) => {
let content = n.innerHTML || n.nodeValue
const content = n.innerHTML || n.nodeValue
if (handled < this.excerptLength && content.trim()) {
excerptParts.push(n)
handled += 1
@ -250,9 +390,9 @@ export default {
return excerptParts.map((p) => { return p.outerHTML }).join('')
},
stats () {
let data = {
const data = {
users: _.get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null),
hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null)
}
if (data.users === null || data.artists === null) {
return
@ -271,16 +411,16 @@ export default {
openRegistrations () {
return _.get(this.nodeinfo, 'openRegistrations')
},
headerStyle() {
headerStyle () {
if (!this.banner) {
return ""
return ''
}
return (
"background-image: url(" +
this.$store.getters["instance/absoluteUrl"](this.banner) +
")"
'background-image: url(' +
this.$store.getters['instance/absoluteUrl'](this.banner) +
')'
)
},
}
},
watch: {
'$store.state.auth.authenticated': {

View File

@ -1,31 +1,50 @@
<template>
<svg version="1.1" id="layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
<g>
<svg
id="layer_1"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 141.7 141.7"
enable-background="new 0 0 141.7 141.7"
xml:space="preserve"
>
<g>
<path :fill="fill" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
<path :fill="fill" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
<g>
<path
:fill="fill"
d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"
/>
<path
:fill="fill"
d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
/>
<path :fill="fill" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
<path
:fill="fill"
d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
C132.2,64.3,131.7,63.8,131.1,63.8z"/>
</g>
<path :fill="fill" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
C132.2,64.3,131.7,63.8,131.1,63.8z"
/>
</g>
<path
:fill="fill"
d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
</g>
c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"
/>
</g>
</svg>
</template>
<script>
export default {
props: {
fill: {type: String, default: '#222222'}
fill: { type: String, default: '#222222' }
}
}
</script>

View File

@ -1,5 +1,8 @@
<template>
<svg viewBox="0 0 271.66678 53.49133" version="1.1">
<svg
viewBox="0 0 271.66678 53.49133"
version="1.1"
>
<g transform="translate(34.65295 -109.48195)">
<g>
<g transform="matrix(.3191 0 0 .3191 -45.91741 93.47184)">
@ -14,7 +17,11 @@
</g>
</g>
</g>
<g transform="translate(-.75595 -.75595)" :fill="text" stroke-width=".74383">
<g
transform="translate(-.75595 -.75595)"
:fill="text"
stroke-width=".74383"
>
<path d="M32.84591 132.89252c0-6.69443 2.6034-9.29781 10.41356-9.29781 1.63641 0 3.71912.14876 4.83486.37191.59506.14876 1.11574.59506 1.11574 1.11574v2.00832c0 .59506-.4463 1.11574-1.11574 1.11574h-.66944c-.8182 0-1.48765-.29753-2.529-.29753-4.83487 0-5.80184.96698-5.80184 4.98363v.29753h6.62004c.59506 0 1.11574.4463 1.11574 1.11574v2.15709c0 .66945-.4463 1.11574-1.11574 1.11574h-6.62004v11.30614c0 .59506-.4463 1.11574-1.11574 1.11574h-4.01666c-.59506 0-1.11574-.52068-1.11574-1.11574z" />
<path d="M57.02023 141.59528c0 3.04968 1.41327 4.31418 3.49598 4.31418 1.78518 0 3.49598-1.2645 4.83487-2.60339v-12.12435c0-.59506.52068-1.11573 1.11574-1.11573h4.09103c.59506 0 1.11574.52067 1.11574 1.11573v17.70304c0 .59506-.4463 1.11574-1.11574 1.11574h-4.09104c-.59505 0-1.11573-.52068-1.11573-1.11574v-1.19012c-1.7108 1.48765-3.57036 2.67777-6.32252 2.67777-4.83486 0-8.25646-2.529-8.25646-8.70275v-10.41355c0-.59506.4463-1.11574 1.11574-1.11574h4.09104c.59506 0 1.11574.52068 1.11574 1.11574v10.33917z" />
<path d="M90.71552 138.47121c0-3.04968-1.41327-4.31419-3.49598-4.31419-1.78518 0-3.57036 1.26451-4.90925 2.60339v12.19874c0 .59506-.4463 1.11573-1.11573 1.11573h-4.09104c-.66945 0-1.11574-.52067-1.11574-1.11573v-17.77743c0-.59506.4463-1.11573 1.11574-1.11573h4.16542c.59506 0 1.11574.52067 1.11574 1.11573v1.19012c1.7108-1.48765 3.57036-2.67777 6.3969-2.67777 4.83486 0 8.25645 2.52901 8.25645 8.70276v10.41355c0 .59506-.4463 1.11574-1.11573 1.11574h-4.09104c-.59506 0-1.11574-.52068-1.11574-1.11574z" />
@ -33,9 +40,9 @@
<script>
export default {
props: {
primary: {type: String, default: '#009fe3'},
secondary: {type: String, default: 'var(--text-color)'},
text: {type: String, default: 'var(--text-color)'},
primary: { type: String, default: '#009fe3' },
secondary: { type: String, default: 'var(--text-color)' },
text: { type: String, default: 'var(--text-color)' }
}
}
</script>

View File

@ -1,19 +1,33 @@
<template>
<main class="main pusher" :v-title="labels.title">
<main
class="main pusher"
:v-title="labels.title"
>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h1 class="ui huge header">
<i class="warning icon"></i>
<i class="warning icon" />
<div class="content">
<translate translate-context="Content/*/Title">Page not found!</translate>
<translate translate-context="Content/*/Title">
Page not found!
</translate>
</div>
</h1>
<p><translate translate-context="Content/*/Paragraph">Sorry, the page you asked for does not exist:</translate></p>
<p>
<translate translate-context="Content/*/Paragraph">
Sorry, the page you asked for does not exist:
</translate>
</p>
<a :href="path">{{ path }}</a>
<div class="ui hidden divider"></div>
<router-link class="ui icon labeled right button" to="/">
<translate translate-context="Content/*/Button.Label/Verb">Go to home page</translate>
<i class="right arrow icon"></i>
<div class="ui hidden divider" />
<router-link
class="ui icon labeled right button"
to="/"
>
<translate translate-context="Content/*/Button.Label/Verb">
Go to home page
</translate>
<i class="right arrow icon" />
</router-link>
</div>
</section>
@ -22,15 +36,15 @@
<script>
export default {
data: function() {
data: function () {
return {
path: window.location.href
}
},
computed: {
labels() {
labels () {
return {
title: this.$pgettext('Head/*/Title', "Page Not Found")
title: this.$pgettext('Head/*/Title', 'Page Not Found')
}
}
}

View File

@ -1,57 +1,68 @@
<template>
<div v-if='maxPage > 1' class="ui pagination menu component-pagination" role="navigation" :aria-label="labels.pagination">
<a href
<div
v-if="maxPage > 1"
class="ui pagination menu component-pagination"
role="navigation"
:aria-label="labels.pagination"
>
<a
href
:disabled="current - 1 < 1"
role="button"
:aria-label="labels.previousPage"
:class="[{'disabled': current - 1 < 1}, 'item']"
@click.prevent.stop="selectPage(current - 1)"
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
><i class="angle left icon" /></a>
<template v-if="!compact">
<a href
<a
v-for="page in pages"
:key="page"
href
:class="[{'active': page === current}, {'disabled': page === 'skip'}, 'item']"
@click.prevent.stop="selectPage(page)"
:class="[{'active': page === current}, {'disabled': page === 'skip'}, 'item']">
>
<span v-if="page !== 'skip'">{{ page }}</span>
<span v-else></span>
</a>
</template>
<a href
<a
href
:disabled="current + 1 > maxPage"
role="button"
:aria-label="labels.nextPage"
:class="[{'disabled': current + 1 > maxPage}, 'item']"
@click.prevent.stop="selectPage(current + 1)"
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
><i class="angle right icon" /></a>
</div>
</template>
<script>
import _ from "@/lodash"
import _ from '@/lodash'
export default {
props: {
current: { type: Number, default: 1 },
paginateBy: { type: Number, default: 25 },
total: { type: Number },
total: { type: Number, required: true },
compact: { type: Boolean, default: false }
},
computed: {
labels() {
labels () {
return {
pagination: this.$pgettext('Content/*/Hidden text/Noun', "Pagination"),
previousPage: this.$pgettext('Content/*/Link', "Previous Page"),
nextPage: this.$pgettext('Content/*/Link', "Next Page")
pagination: this.$pgettext('Content/*/Hidden text/Noun', 'Pagination'),
previousPage: this.$pgettext('Content/*/Link', 'Previous Page'),
nextPage: this.$pgettext('Content/*/Link', 'Next Page')
}
},
pages: function() {
let range = 2
let current = this.current
let beginning = _.range(1, Math.min(this.maxPage, 1 + range))
let middle = _.range(
pages: function () {
const range = 2
const current = this.current
const beginning = _.range(1, Math.min(this.maxPage, 1 + range))
const middle = _.range(
Math.max(1, current - range + 1),
Math.min(this.maxPage, current + range)
)
let end = _.range(this.maxPage, Math.max(1, this.maxPage - range))
const end = _.range(this.maxPage, Math.max(1, this.maxPage - range))
let allowed = beginning.concat(middle, end)
allowed = _.uniq(allowed)
allowed = _.sortBy(allowed, [
@ -59,11 +70,11 @@ export default {
return e
}
])
let final = []
const final = []
allowed.forEach(p => {
let last = final.slice(-1)[0]
const last = final.slice(-1)[0]
let consecutive = true
if (last === "skip") {
if (last === 'skip') {
consecutive = false
} else {
if (!last) {
@ -75,25 +86,25 @@ export default {
if (consecutive) {
final.push(p)
} else {
if (p !== "skip") {
final.push("skip")
if (p !== 'skip') {
final.push('skip')
final.push(p)
}
}
})
return final
},
maxPage: function() {
maxPage: function () {
return Math.ceil(this.total / this.paginateBy)
}
},
methods: {
selectPage: function(page) {
selectPage: function (page) {
if (page > this.maxPage || page < 1) {
return
}
if (this.current !== page) {
this.$emit("page-changed", page)
this.$emit('page-changed', page)
}
}
}

View File

@ -1,77 +1,148 @@
<template>
<section class="main with-background component-queue" :aria-label="labels.queue">
<section
class="main with-background component-queue"
:aria-label="labels.queue"
>
<div :class="['ui vertical stripe queue segment', playerFocused ? 'player-focused' : '']">
<div class="ui fluid container">
<div class="ui stackable grid" id="queue-grid">
<div
id="queue-grid"
class="ui stackable grid"
>
<div class="ui six wide column current-track">
<div class="ui basic segment" id="player">
<div
id="player"
class="ui basic segment"
>
<template v-if="currentTrack">
<img ref="cover" alt="" v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop" :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)">
<img ref="cover" alt="" v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)">
<img class="ui image" alt="" v-else src="../assets/audio/default-cover.png">
<img
v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)"
>
<img
v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)"
>
<img
v-else
class="ui image"
alt=""
src="../assets/audio/default-cover.png"
>
<h1 class="ui header">
<div class="content ellipsis">
<router-link class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
<router-link
class="small header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
>
{{ currentTrack.title }}
</router-link>
<div class="sub header ellipsis">
<router-link class="discrete link artist" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">{{ currentTrack.artist.name }}</router-link>
<template v-if="currentTrack.album"> /
<router-link class="discrete link album" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">{{ currentTrack.album.title }}</router-link>
<router-link
class="discrete link artist"
:to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"
>
{{ currentTrack.artist.name }}
</router-link>
<template v-if="currentTrack.album">
/
<router-link
class="discrete link album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"
>
{{ currentTrack.album.title }}
</router-link>
</template>
</div>
</div>
</h1>
<div class="ui small warning message" v-if="currentTrack && errored">
<div
v-if="currentTrack && errored"
class="ui small warning message"
>
<h3 class="header">
<translate translate-context="Sidebar/Player/Error message.Title">The track cannot be loaded</translate>
<translate translate-context="Sidebar/Player/Error message.Title">
The track cannot be loaded
</translate>
</h3>
<p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
<translate translate-context="Sidebar/Player/Error message.Paragraph">The next track will play automatically in a few seconds</translate>
<i class="loading spinner icon"></i>
<translate translate-context="Sidebar/Player/Error message.Paragraph">
The next track will play automatically in a few seconds
</translate>
<i class="loading spinner icon" />
</p>
<p>
<translate translate-context="Sidebar/Player/Error message.Paragraph">You may have a connectivity issue.</translate>
<translate translate-context="Sidebar/Player/Error message.Paragraph">
You may have a connectivity issue.
</translate>
</p>
</div>
<div class="additional-controls tablet-and-below">
<track-favorite-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"></track-favorite-icon>
:track="currentTrack"
/>
<track-playlist-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"></track-playlist-icon>
:track="currentTrack"
/>
<button
v-if="$store.state.auth.authenticated"
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
:aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter">
<i :class="['eye slash outline', 'basic', 'icon']"></i>
:title="labels.addArtistContentFilter"
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button>
</div>
<div class="progress-wrapper">
<div class="progress-area" v-if="currentTrack && !errored">
<div
v-if="currentTrack && !errored"
class="progress-area"
>
<div
ref="progress"
:class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']"
@click="touchProgress">
<div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
<div class="position bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
@click="touchProgress"
>
<div
class="buffer bar"
:data-percent="bufferProgress"
:style="{ 'width': bufferProgress + '%' }"
/>
<div
class="position bar"
:data-percent="progress"
:style="{ 'width': progress + '%' }"
/>
</div>
</div>
<div class="progress-area" v-else>
<div
v-else
class="progress-area"
>
<div
ref="progress"
:class="['ui', 'small', 'vibrant', 'progress']">
<div class="buffer bar"></div>
<div class="position bar"></div>
:class="['ui', 'small', 'vibrant', 'progress']"
>
<div class="buffer bar" />
<div class="position bar" />
</div>
</div>
<div class="progress">
<template v-if="!isLoadingAudio">
<a href="" :aria-label="labels.restart" class="left floated timer discrete start" @click.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</a>
<span class="right floated timer total">{{durationFormatted}}</span>
<a
href=""
:aria-label="labels.restart"
class="left floated timer discrete start"
@click.prevent="setCurrentTime(0)"
>{{ currentTimeFormatted }}</a>
<span class="right floated timer total">{{ durationFormatted }}</span>
</template>
<template v-else>
<span class="left floated timer">00:00</span>
@ -80,45 +151,47 @@
</div>
</div>
<div class="player-controls tablet-and-below">
<template>
<span
role="button"
:title="labels.previousTrack"
:aria-label="labels.previousTrack"
class="control"
@click.prevent.stop="$store.dispatch('queue/previous')"
:disabled="emptyQueue">
<i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']"></i>
</span>
<span
role="button"
:title="labels.previousTrack"
:aria-label="labels.previousTrack"
class="control"
:disabled="emptyQueue"
@click.prevent.stop="$store.dispatch('queue/previous')"
>
<i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" />
</span>
<span
role="button"
v-if="!playing"
:title="labels.play"
:aria-label="labels.play"
@click.prevent.stop="resumePlayback"
class="control">
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']"></i>
</span>
<span
role="button"
v-else
:title="labels.pause"
:aria-label="labels.pause"
@click.prevent.stop="pausePlayback"
class="control">
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']"></i>
</span>
<span
role="button"
:title="labels.next"
:aria-label="labels.next"
class="control"
@click.prevent.stop="$store.dispatch('queue/next')"
:disabled="!hasNext">
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" ></i>
</span>
</template>
<span
v-if="!playing"
role="button"
:title="labels.play"
:aria-label="labels.play"
class="control"
@click.prevent.stop="resumePlayback"
>
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" />
</span>
<span
v-else
role="button"
:title="labels.pause"
:aria-label="labels.pause"
class="control"
@click.prevent.stop="pausePlayback"
>
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" />
</span>
<span
role="button"
:title="labels.next"
:aria-label="labels.next"
class="control"
:disabled="!hasNext"
@click.prevent.stop="$store.dispatch('queue/next')"
>
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" />
</span>
</div>
</template>
</div>
@ -129,20 +202,30 @@
<div class="content">
<button
class="ui right floated basic button"
@click="$store.commit('ui/queueFocused', null)">
<translate translate-context="*/Queue/*/Verb">Close</translate>
@click="$store.commit('ui/queueFocused', null)"
>
<translate translate-context="*/Queue/*/Verb">
Close
</translate>
</button>
<button
class="ui right floated basic button danger"
@click="$store.dispatch('queue/clean')">
<translate translate-context="*/Queue/*/Verb">Clear</translate>
@click="$store.dispatch('queue/clean')"
>
<translate translate-context="*/Queue/*/Verb">
Clear
</translate>
</button>
{{ labels.queue }}
<div class="sub header">
<div>
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
<translate
translate-context="Sidebar/Queue/Text"
:translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"
>
Track %{ index } of %{ length }
</translate><template v-if="!$store.state.radios.running"> -
</translate><template v-if="!$store.state.radios.running">
-
<span :title="labels.duration">
{{ timeLeft }}
</span>
@ -153,22 +236,53 @@
</h2>
</div>
<table class="ui compact very basic fixed single line selectable unstackable table">
<draggable v-model="tracks" tag="tbody" @update="reorder" handle=".handle">
<draggable
v-model="tracks"
tag="tbody"
handle=".handle"
@update="reorder"
>
<tr
v-for="(track, index) in tracks"
:key="index"
:class="['queue-item', {'active': index === queue.currentIndex}]">
:class="['queue-item', {'active': index === queue.currentIndex}]"
>
<td class="handle">
<i class="grip lines icon"></i>
<i class="grip lines icon" />
</td>
<td class="image-cell" @click="$store.dispatch('queue/currentIndex', index)">
<img class="ui mini image" alt="" v-if="track.cover && track.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)">
<img class="ui mini image" alt="" v-else-if="track.album && track.album.cover && track.album.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)">
<img class="ui mini image" alt="" v-else src="../assets/audio/default-cover.png">
<td
class="image-cell"
@click="$store.dispatch('queue/currentIndex', index)"
>
<img
v-if="track.cover && track.cover.urls.original"
class="ui mini image"
alt=""
:src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
>
<img
v-else-if="track.album && track.album.cover && track.album.cover.urls.original"
class="ui mini image"
alt=""
:src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
>
<img
v-else
class="ui mini image"
alt=""
src="../assets/audio/default-cover.png"
>
</td>
<td colspan="3" @click="$store.dispatch('queue/currentIndex', index)">
<button class="title reset ellipsis" :title="track.title" :aria-label="labels.selectTrack">
<strong>{{ track.title }}</strong><br />
<td
colspan="3"
@click="$store.dispatch('queue/currentIndex', index)"
>
<button
class="title reset ellipsis"
:title="track.title"
:aria-label="labels.selectTrack"
>
<strong>{{ track.title }}</strong><br>
<span>
{{ track.artist.name }}
</span>
@ -181,23 +295,44 @@
</td>
<td class="controls">
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
<i class="pink heart icon"></i>
<i class="pink heart icon" />
</template>
<button :aria-label="labels.removeFromQueue" :title="labels.removeFromQueue" @click.stop="cleanTrack(index)" :class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']">
<i class="x icon"></i>
<button
:aria-label="labels.removeFromQueue"
:title="labels.removeFromQueue"
:class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"
@click.stop="cleanTrack(index)"
>
<i class="x icon" />
</button>
</td>
</tr>
</draggable>
</table>
<div v-if="$store.state.radios.running" class="ui info message">
<div
v-if="$store.state.radios.running"
class="ui info message"
>
<div class="content">
<h3 class="header">
<i class="feed icon"></i> <translate translate-context="Sidebar/Player/Title">You have a radio playing</translate>
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
You have a radio playing
</translate>
</h3>
<p><translate translate-context="Sidebar/Player/Paragraph">New tracks will be appended here automatically.</translate></p>
<button @click="$store.dispatch('radios/stop')" class="ui basic primary button"><translate translate-context="*/Player/Button.Label/Short, Verb">Stop radio</translate></button>
<p>
<translate translate-context="Sidebar/Player/Paragraph">
New tracks will be appended here automatically.
</translate>
</p>
<button
class="ui basic primary button"
@click="$store.dispatch('radios/stop')"
>
<translate translate-context="*/Player/Button.Label/Short, Verb">
Stop radio
</translate>
</button>
</div>
</div>
</div>
@ -229,16 +364,6 @@ export default {
time
}
},
mounted () {
this.focusTrap = createFocusTrap(this.$el, { allowOutsideClick: () => { return true } })
this.focusTrap.activate()
this.$nextTick(() => {
setTimeout(() => {
this.scrollToCurrent()
// delay is to let transition work
}, 400)
})
},
computed: {
...mapState({
currentIndex: state => state.queue.currentIndex,
@ -298,6 +423,46 @@ export default {
return this.$store.state.ui.queueFocused === 'player'
}
},
watch: {
'$store.state.ui.queueFocused': {
handler (v) {
if (v === 'queue') {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
immediate: true
},
'$store.state.queue.currentIndex': {
handler () {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
'$store.state.queue.tracks': {
handler (v) {
if (!v || v.length === 0) {
this.$store.commit('ui/queueFocused', null)
}
},
immediate: true
},
'$route.fullPath' () {
this.$store.commit('ui/queueFocused', null)
}
},
mounted () {
this.focusTrap = createFocusTrap(this.$el, { allowOutsideClick: () => { return true } })
this.focusTrap.activate()
this.$nextTick(() => {
setTimeout(() => {
this.scrollToCurrent()
// delay is to let transition work
}, 400)
})
},
methods: {
...mapActions({
cleanTrack: 'queue/cleanTrack',
@ -348,36 +513,6 @@ export default {
})
}, 100)
}
},
watch: {
'$store.state.ui.queueFocused': {
handler (v) {
if (v === 'queue') {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
immediate: true
},
'$store.state.queue.currentIndex': {
handler () {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
'$store.state.queue.tracks': {
handler (v) {
if (!v || v.length === 0) {
this.$store.commit('ui/queueFocused', null)
}
},
immediate: true
},
'$route.fullPath' () {
this.$store.commit('ui/queueFocused', null)
}
}
}
</script>

View File

@ -1,19 +1,51 @@
<template>
<div v-if="type === 'both' || type === undefined" class="two ui buttons">
<button class="ui left floated labeled icon button" @click.prevent="changeType('rss')"><i class="feed icon"></i>
<translate translate-context="Content/Search/Input.Label/Noun">RSS</translate>
<div
v-if="type === 'both' || type === undefined"
class="two ui buttons"
>
<button
class="ui left floated labeled icon button"
@click.prevent="changeType('rss')"
>
<i class="feed icon" />
<translate translate-context="Content/Search/Input.Label/Noun">
RSS
</translate>
</button>
<div class="or"></div>
<button class="ui right floated right labeled icon button" @click.prevent="changeType('artists')"><i class="globe icon"></i>
<translate translate-context="Content/Search/Input.Label/Noun">Fediverse</translate>
<div class="or" />
<button
class="ui right floated right labeled icon button"
@click.prevent="changeType('artists')"
>
<i class="globe icon" />
<translate translate-context="Content/Search/Input.Label/Noun">
Fediverse
</translate>
</button>
</div>
<div v-else>
<form id="remote-search" :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h3 class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></h3>
<form
id="remote-search"
:class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent="submit"
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h3 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while fetching object
</translate>
</h3>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="ui required field">
@ -21,19 +53,45 @@
{{ labels.fieldLabel }}
</label>
<p v-if="type === 'rss'">
<translate translate-context="Content/Fetch/Paragraph">Use this form to subscribe to an RSS feed from its URL.</translate>
<translate translate-context="Content/Fetch/Paragraph">
Use this form to subscribe to an RSS feed from its URL.
</translate>
</p>
<p v-else-if="type === 'artists'">
<translate translate-context="Content/Fetch/Paragraph">Use this form to subscribe to a channel hosted somewhere else on the Fediverse.</translate>
<translate translate-context="Content/Fetch/Paragraph">
Use this form to subscribe to a channel hosted somewhere else on the Fediverse.
</translate>
</p>
<input type="text" name="object-id" id="object-id" :placeholder="labels.fieldPlaceholder" v-model="id" required>
<input
id="object-id"
v-model="id"
type="text"
name="object-id"
:placeholder="labels.fieldPlaceholder"
required
>
</div>
<button v-if="showSubmit" type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0">
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
<button
v-if="showSubmit"
type="submit"
:class="['ui', 'primary', {loading: isLoading}, 'button']"
:disabled="isLoading || !id || id.length === 0"
>
<translate translate-context="Content/Search/Input.Label/Noun">
Search
</translate>
</button>
</form>
<div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" role="alert" class="ui warning message">
<p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
<div
v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute"
role="alert"
class="ui warning message"
>
<p>
<translate translate-context="Content/*/Error message.Title">
This kind of object isn't supported yet
</translate>
</p>
</div>
</div>
</template>
@ -42,20 +100,87 @@ import axios from 'axios'
export default {
props: {
initialId: { type: String, required: false},
type: { type: String, required: false},
redirect: { type: Boolean, default: true},
showSubmit: { type: Boolean, default: true},
standalone: { type: Boolean, default: true},
initialId: { type: String, required: false, default: '' },
initialType: { type: String, required: false, default: '' },
redirect: { type: Boolean, default: true },
showSubmit: { type: Boolean, default: true },
standalone: { type: Boolean, default: true }
},
data () {
return {
type: this.initialType,
id: this.initialId,
fetch: null,
obj: null,
isLoading: false,
errors: [],
errors: []
}
},
computed: {
labels () {
let title = ''
let fieldLabel = ''
let fieldPlaceholder = ''
if (this.type === 'rss') {
title = this.$pgettext('Head/Fetch/Title', 'Subscribe to a podcast RSS feed')
fieldLabel = this.$pgettext('*/*/*', 'RSS feed location')
fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', 'https://website.example.com/rss.xml')
} else if (this.type === 'artists') {
title = this.$pgettext('Head/Fetch/Title', 'Subscribe to a podcast hosted on the Fediverse')
fieldLabel = this.$pgettext('*/*/*', 'Fediverse object')
fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', '@username@example.com')
}
return {
title,
fieldLabel,
fieldPlaceholder
}
},
objInfo () {
if (this.fetch && this.fetch.status === 'finished') {
return this.fetch.object
}
return null
},
redirectRoute () {
if (!this.objInfo) {
return
}
switch (this.objInfo.type) {
case 'account': {
const [username, domain] = this.objInfo.full_username.split('@')
return { name: 'profile.full', params: { username, domain } }
}
case 'library':
return { name: 'library.detail', params: { id: this.objInfo.uuid } }
case 'artist':
return { name: 'library.artists.detail', params: { id: this.objInfo.id } }
case 'album':
return { name: 'library.albums.detail', params: { id: this.objInfo.id } }
case 'track':
return { name: 'library.tracks.detail', params: { id: this.objInfo.id } }
case 'upload':
return { name: 'library.uploads.detail', params: { id: this.objInfo.uuid } }
case 'channel':
return { name: 'channels.detail', params: { id: this.objInfo.uuid } }
default:
break
}
return null
}
},
watch: {
initialId (v) {
this.id = v
this.createFetch()
},
redirectRoute (v) {
if (v && this.redirect) {
this.$router.push(v)
}
}
},
created () {
@ -67,60 +192,9 @@ export default {
}
}
},
computed: {
labels() {
let title = ""
let fieldLabel = ""
let fieldPlaceholder = ""
if (this.type === "rss") {
title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
fieldLabel = this.$pgettext('*/*/*', "RSS feed location")
fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', "https://website.example.com/rss.xml")
} else if (this.type === 'artists') {
title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast hosted on the Fediverse")
fieldLabel = this.$pgettext('*/*/*', "Fediverse object")
fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', "@username@example.com")
}
return {
title,
fieldLabel,
fieldPlaceholder,
}
},
objInfo () {
if (this.fetch && this.fetch.status === 'finished') {
return this.fetch.object
}
},
redirectRoute () {
if (!this.objInfo) {
return
}
switch (this.objInfo.type) {
case 'account':
let [username, domain] = this.objInfo.full_username.split('@')
return {name: 'profile.full', params: {username, domain}}
case 'library':
return {name: 'library.detail', params: {id: this.objInfo.uuid}}
case 'artist':
return {name: 'library.artists.detail', params: {id: this.objInfo.id}}
case 'album':
return {name: 'library.albums.detail', params: {id: this.objInfo.id}}
case 'track':
return {name: 'library.tracks.detail', params: {id: this.objInfo.id}}
case 'upload':
return {name: 'library.uploads.detail', params: {id: this.objInfo.uuid}}
case 'channel':
return {name: 'channels.detail', params: {id: this.objInfo.uuid}}
default:
break;
}
}
},
methods: {
changeType(newType) {
changeType (newType) {
this.type = newType
},
submit () {
@ -135,13 +209,13 @@ export default {
return
}
if (this.standalone) {
this.$router.replace({name: "search", query: {id: this.id}})
this.$router.replace({ name: 'search', query: { id: this.id } })
}
this.fetch = null
let self = this
const self = this
self.errors = []
self.isLoading = true
let payload = {
const payload = {
object: this.id
}
@ -150,7 +224,7 @@ export default {
self.fetch = response.data
if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
self.errors.push(
self.$pgettext("Content/*/Error message.Title", "This object cannot be retrieved")
self.$pgettext('Content/*/Error message.Title', 'This object cannot be retrieved')
)
}
}, error => {
@ -163,40 +237,27 @@ export default {
return
}
if (this.standalone) {
this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
this.$router.replace({ name: 'search', query: { id: this.id, type: 'rss' } })
}
this.fetch = null
let self = this
const self = this
self.errors = []
self.isLoading = true
let payload = {
const payload = {
url: this.id
}
axios.post('channels/rss-subscribe/', payload).then((response) => {
self.isLoading = false
self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
self.$store.commit('channels/subscriptions', { uuid: response.data.channel.uuid, value: true })
self.$emit('subscribed', response.data)
if (self.redirect) {
self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
self.$router.push({ name: 'channels.detail', params: { id: response.data.channel.uuid } })
}
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
},
watch: {
initialId (v) {
this.id = v
this.createFetch()
},
redirectRoute (v) {
if (v && this.redirect) {
this.$router.push(v)
}
}
}
}

View File

@ -1,7 +1,11 @@
<template>
<div class="ui toast-container">
<message v-for="message in $store.state.ui.messages" :message="message" :key="message.key"></message>
<slot></slot>
<message
v-for="message in $store.state.ui.messages"
:key="message.key"
:message="message"
/>
<slot />
</div>
</template>

View File

@ -1,41 +1,105 @@
<template>
<modal @update:show="$emit('update:show', $event); isError = false" :show="show">
<h3 class="header"><translate translate-context="Popup/Instance/Title">Choose your instance</translate></h3>
<modal
:show="show"
@update:show="$emit('update:show', $event); isError = false"
>
<h3 class="header">
<translate translate-context="Popup/Instance/Title">
Choose your instance
</translate>
</h3>
<div class="scrolling content">
<div v-if="isError" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Popup/Instance/Error message.Title">It is not possible to connect to the given URL</translate></h4>
<div
v-if="isError"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Popup/Instance/Error message.Title">
It is not possible to connect to the given URL
</translate>
</h4>
<ul class="list">
<li><translate translate-context="Popup/Instance/Error message.List item">The server might be down</translate></li>
<li><translate translate-context="Popup/Instance/Error message.List item">The given address is not a Funkwhale server</translate></li>
<li>
<translate translate-context="Popup/Instance/Error message.List item">
The server might be down
</translate>
</li>
<li>
<translate translate-context="Popup/Instance/Error message.List item">
The given address is not a Funkwhale server
</translate>
</li>
</ul>
</div>
<form class="ui form" @submit.prevent="checkAndSwitch(instanceUrl)">
<p v-if="$store.state.instance.instanceUrl" class="description" translate-context="Popup/Login/Paragraph" v-translate="{url: $store.state.instance.instanceUrl, hostname: instanceHostname }">
You are currently connected to <a href="%{ url }" target="_blank">%{ hostname }&nbsp;<i class="external icon"></i></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted.
<form
class="ui form"
@submit.prevent="checkAndSwitch(instanceUrl)"
>
<p
v-if="$store.state.instance.instanceUrl"
v-translate="{url: $store.state.instance.instanceUrl, hostname: instanceHostname }"
class="description"
translate-context="Popup/Login/Paragraph"
>
You are currently connected to <a
href="%{ url }"
target="_blank"
>%{ hostname }&nbsp;<i class="external icon" /></a>. If you continue, you will be disconnected from your current instance and all your local data will be deleted.
</p>
<p v-else>
<translate translate-context="Popup/Instance/Paragraph">To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.</translate>
<translate translate-context="Popup/Instance/Paragraph">
To continue, please select the Funkwhale instance you want to connect to. Enter the address directly, or select one of the suggested choices.
</translate>
</p>
<div class="field">
<label for="instance-picker"><translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate></label>
<div class="ui action input">
<input id ="instance-picker" type="text" v-model="instanceUrl" placeholder="https://funkwhale.server">
<button type="submit" :class="['ui', 'icon', {loading: isLoading}, 'button']">
<translate translate-context="*/*/Button.Label/Verb">Submit</translate>
<input
id="instance-picker"
v-model="instanceUrl"
type="text"
placeholder="https://funkwhale.server"
>
<button
type="submit"
:class="['ui', 'icon', {loading: isLoading}, 'button']"
>
<translate translate-context="*/*/Button.Label/Verb">
Submit
</translate>
</button>
</div>
</div>
</form>
<div class="ui hidden divider"></div>
<form class="ui form" @submit.prevent="">
<div class="ui hidden divider" />
<form
class="ui form"
@submit.prevent=""
>
<div class="field">
<h4><translate translate-context="Popup/Instance/List.Label">Suggested choices</translate></h4>
<button v-for="url in suggestedInstances" @click="checkAndSwitch(url)" class="ui basic button">{{ url }}</button>
<h4>
<translate translate-context="Popup/Instance/List.Label">
Suggested choices
</translate>
</h4>
<button
v-for="(url, key) in suggestedInstances"
:key="key"
class="ui basic button"
@click="checkAndSwitch(url)"
>
{{ url }}
</button>
</div>
</form>
</div>
<div class="actions">
<button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
<button class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">
Cancel
</translate>
</button>
</div>
</modal>
</template>
@ -43,25 +107,52 @@
<script>
import Modal from '@/components/semantic/Modal'
import axios from 'axios'
import _ from "@/lodash"
import _ from '@/lodash'
export default {
props: ['show'],
components: {
Modal,
Modal
},
data() {
props: { show: { type: Boolean, required: true } },
data () {
return {
instanceUrl: null,
nodeinfo: null,
isError: false,
isLoading: false,
path: 'api/v1/instance/nodeinfo/2.0/',
path: 'api/v1/instance/nodeinfo/2.0/'
}
},
computed: {
suggestedInstances () {
const instances = this.$store.state.instance.knownInstances.slice(0)
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
if (!serverUrl.endsWith('/')) {
serverUrl = serverUrl + '/'
}
instances.push(serverUrl)
}
const self = this
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
return _.uniq(instances.filter((e) => { return e !== self.$store.state.instance.instanceUrl }))
},
instanceHostname () {
const url = this.$store.state.instance.instanceUrl
const parser = document.createElement('a')
parser.href = url
return parser.hostname
}
},
watch: {
'$store.state.instance.instanceUrl' () {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
}
},
methods: {
fetchNodeInfo () {
let self = this
const self = this
axios.get('instance/nodeinfo/2.0/').then(response => {
self.nodeinfo = response.data
})
@ -71,7 +162,7 @@ export default {
if (!urlFetch.endsWith('/')) {
urlFetch = `${urlFetch}/${this.path}`
} else {
urlFetch = `${urlFetch}${this.path}`
urlFetch = `${urlFetch}${this.path}`
}
if (!urlFetch.startsWith('https://') && !urlFetch.startsWith('http://')) {
urlFetch = `https://${urlFetch}`
@ -79,14 +170,14 @@ export default {
return urlFetch
},
requestDistantNodeInfo (url) {
var self = this
const self = this
axios.get(this.fetchUrl(url)).then(function (response) {
self.isLoading = false
if(!url.startsWith('https://') && !url.startsWith('http://')) {
if (!url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}`
}
self.switchInstance(url)
}).catch(function (error) {
}).catch(function () {
self.isLoading = false
self.isError = true
})
@ -95,12 +186,12 @@ export default {
// Here we disconnect from the current instance and reconnect to the new one. No check is performed
this.$emit('update:show', false)
this.isError = false
let msg = this.$pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }')
const msg = this.$pgettext('*/Instance/Message', 'You are now using the Funkwhale instance at %{ url }')
this.$store.commit('ui/addMessage', {
content: this.$gettextInterpolate(msg, {url: url}),
content: this.$gettextInterpolate(msg, { url: url }),
date: new Date()
})
let self = this
const self = this
this.$nextTick(() => {
self.$store.commit('instance/instanceUrl', null)
self.$store.dispatch('instance/setUrl', url)
@ -111,34 +202,7 @@ export default {
this.isError = false // Clear error message if any
this.isLoading = true
this.requestDistantNodeInfo(url)
},
},
computed: {
suggestedInstances () {
let instances = this.$store.state.instance.knownInstances.slice(0)
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
if (!serverUrl.endsWith('/')) {
serverUrl = serverUrl + '/'
}
instances.push(serverUrl)
}
let self = this
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
return _.uniq(instances.filter((e) => {return e != self.$store.state.instance.instanceUrl}))
},
instanceHostname() {
let url = this.$store.state.instance.instanceUrl
let parser = document.createElement("a")
parser.href = url
return parser.hostname
},
},
watch: {
'$store.state.instance.instanceUrl' () {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
},
},
}
}
}
</script>

View File

@ -1,42 +1,59 @@
<template>
<modal @update:show="$emit('update:show', $event)" :show="show">
<modal
:show="show"
@update:show="$emit('update:show', $event)"
>
<header class="header">
<translate translate-context="*/*/*/Noun">Keyboard shortcuts</translate>
<translate translate-context="*/*/*/Noun">
Keyboard shortcuts
</translate>
</header>
<section class="scrolling content">
<div class="ui stackable two column grid">
<div class="column">
<table
class="ui compact basic table"
v-for="section in player"
:key="section.title">
<caption>{{ section.title }}</caption>
<tbody>
<tr v-for="shortcut in section.shortcuts" :key="shortcut.summary">
<td>{{ shortcut.summary }}</td>
<td><span class="ui label">{{ shortcut.key }}</span></td>
</tr>
</tbody>
:key="section.title"
class="ui compact basic table"
>
<caption>{{ section.title }}</caption>
<tbody>
<tr
v-for="shortcut in section.shortcuts"
:key="shortcut.summary"
>
<td>{{ shortcut.summary }}</td>
<td><span class="ui label">{{ shortcut.key }}</span></td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<table
class="ui compact basic table"
v-for="section in general"
:key="section.title">
<caption>{{ section.title }}</caption>
<tbody>
<tr v-for="shortcut in section.shortcuts" :key="shortcut.summary">
<td>{{ shortcut.summary }}</td>
<td><span class="ui label">{{ shortcut.key }}</span></td>
</tr>
</tbody>
:key="section.title"
class="ui compact basic table"
>
<caption>{{ section.title }}</caption>
<tbody>
<tr
v-for="shortcut in section.shortcuts"
:key="shortcut.summary"
>
<td>{{ shortcut.summary }}</td>
<td><span class="ui label">{{ shortcut.key }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<footer class="actions">
<button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Close</translate></button>
<button class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">
Close
</translate>
</button>
</footer>
</modal>
</template>
@ -44,10 +61,10 @@
<script>
export default {
props: ['show'],
components: {
Modal: () => import(/* webpackChunkName: "modal" */ "@/components/semantic/Modal"),
Modal: () => import(/* webpackChunkName: "modal" */ '@/components/semantic/Modal')
},
props: { show: { type: Boolean, required: true } },
computed: {
general () {
return [
@ -65,9 +82,9 @@ export default {
{
key: 'esc',
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Unfocus searchbar')
},
}
]
},
}
]
},
@ -135,7 +152,7 @@ export default {
{
key: 'f',
summary: this.$pgettext('Popup/Keyboard shortcuts/Table.Label/Verb', 'Toggle favorite')
},
}
]
}
]

View File

@ -1,86 +1,156 @@
<template>
<form :id="group.id" class="ui form component-settings-group" @submit.prevent="save">
<form
:id="group.id"
class="ui form component-settings-group"
@submit.prevent="save"
>
<div class="ui divider" />
<h3 class="ui header">{{ group.label }}</h3>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving settings</translate></h4>
<h3 class="ui header">
{{ group.label }}
</h3>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while saving settings
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div v-if="result" class="ui positive message">
<translate translate-context="Content/Settings/Paragraph">Settings updated successfully.</translate>
<div
v-if="result"
class="ui positive message"
>
<translate translate-context="Content/Settings/Paragraph">
Settings updated successfully.
</translate>
</div>
<p v-if="group.help">{{ group.help }}</p>
<div v-for="setting in settings" class="ui field">
<p v-if="group.help">
{{ group.help }}
</p>
<div
v-for="(setting, key) in settings"
:key="key"
class="ui field"
>
<template v-if="setting.field.widget.class !== 'CheckboxInput'">
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text">{{ setting.help_text }}</p>
<p v-if="setting.help_text">
{{ setting.help_text }}
</p>
</template>
<content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
<content-form
v-if="setting.fieldType === 'markdown'"
v-model="values[setting.identifier]"
v-bind="setting.fieldParams"
/>
<signup-form-builder
v-else-if="setting.fieldType === 'formBuilder'"
:value="values[setting.identifier]"
:signup-approval-enabled="values.moderation__signup_approval_enabled"
@input="set(setting.identifier, $event)" />
@input="set(setting.identifier, $event)"
/>
<input
:id="setting.identifier"
:name="setting.identifier"
v-else-if="setting.field.widget.class === 'PasswordInput'"
:id="setting.identifier"
v-model="values[setting.identifier]"
:name="setting.identifier"
type="password"
class="ui input"
v-model="values[setting.identifier]" />
>
<input
:id="setting.identifier"
:name="setting.identifier"
v-else-if="setting.field.widget.class === 'TextInput'"
:id="setting.identifier"
v-model="values[setting.identifier]"
:name="setting.identifier"
type="text"
class="ui input"
v-model="values[setting.identifier]" />
>
<input
:id="setting.identifier"
:name="setting.identifier"
v-else-if="setting.field.class === 'IntegerField'"
:id="setting.identifier"
v-model.number="values[setting.identifier]"
:name="setting.identifier"
type="number"
class="ui input"
v-model.number="values[setting.identifier]" />
>
<textarea
:id="setting.identifier"
:name="setting.identifier"
v-else-if="setting.field.widget.class === 'Textarea'"
:id="setting.identifier"
v-model="values[setting.identifier]"
:name="setting.identifier"
type="text"
class="ui input"
v-model="values[setting.identifier]" />
<div v-else-if="setting.field.widget.class === 'CheckboxInput'" class="ui toggle checkbox">
/>
<div
v-else-if="setting.field.widget.class === 'CheckboxInput'"
class="ui toggle checkbox"
>
<input
:id="setting.identifier"
:name="setting.identifier"
v-model="values[setting.identifier]"
type="checkbox" />
:name="setting.identifier"
type="checkbox"
>
<label :for="setting.identifier">{{ setting.verbose_name }}</label>
<p v-if="setting.help_text">{{ setting.help_text }}</p>
<p v-if="setting.help_text">
{{ setting.help_text }}
</p>
</div>
<select
:id="setting.identifier"
v-else-if="setting.field.class === 'MultipleChoiceField'"
:id="setting.identifier"
v-model="values[setting.identifier]"
multiple
class="ui search selection dropdown">
<option v-for="v in setting.additional_data.choices" :value="v[0]">{{ v[1] }}</option>
class="ui search selection dropdown"
>
<option
v-for="(v, index) in setting.additional_data.choices"
:key="index"
:value="v[0]"
>
{{ v[1] }}
</option>
</select>
<div v-else-if="setting.field.widget.class === 'ImageWidget'">
<input :id="setting.identifier" type="file" :ref="setting.identifier">
<input
:id="setting.identifier"
:ref="setting.identifier"
type="file"
>
<div v-if="values[setting.identifier]">
<div class="ui hidden divider"></div>
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Noun">Current image</translate></h3>
<img class="ui image" alt="" v-if="values[setting.identifier]" :src="$store.getters['instance/absoluteUrl'](values[setting.identifier])" />
<div class="ui hidden divider" />
<h3 class="ui header">
<translate translate-context="Content/Settings/Title/Noun">
Current image
</translate>
</h3>
<img
v-if="values[setting.identifier]"
class="ui image"
alt=""
:src="$store.getters['instance/absoluteUrl'](values[setting.identifier])"
>
</div>
</div>
</div>
<button
type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Save</translate>
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'success', 'button']"
>
<translate translate-context="Content/*/Button.Label/Verb">
Save
</translate>
</button>
</form>
</template>
@ -91,12 +161,12 @@ import axios from 'axios'
import lodash from '@/lodash'
export default {
props: {
group: {type: Object, required: true},
settingsData: {type: Array, required: true}
},
components: {
SignupFormBuilder: () => import(/* webpackChunkName: "signup-form-builder" */ "@/components/admin/SignupFormBuilder"),
SignupFormBuilder: () => import(/* webpackChunkName: "signup-form-builder" */ '@/components/admin/SignupFormBuilder')
},
props: {
group: { type: Object, required: true },
settingsData: { type: Array, required: true }
},
data () {
return {
@ -106,28 +176,44 @@ export default {
isLoading: false
}
},
computed: {
settings () {
const byIdentifier = {}
this.settingsData.forEach(e => {
byIdentifier[e.identifier] = e
})
return this.group.settings.map(e => {
return { ...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {} }
})
},
fileSettings () {
return this.settings.filter((s) => {
return s.field.widget.class === 'ImageWidget'
})
}
},
created () {
let self = this
const self = this
this.settings.forEach(e => {
self.values[e.identifier] = e.value
})
},
methods: {
save () {
let self = this
const self = this
this.isLoading = true
self.errors = []
self.result = null
let postData = self.values
let contentType = 'application/json'
let fileSettingsIDs = this.fileSettings.map((s) => {return s.identifier})
const fileSettingsIDs = this.fileSettings.map((s) => { return s.identifier })
if (fileSettingsIDs.length > 0) {
contentType = 'multipart/form-data'
postData = new FormData()
this.settings.forEach((s) => {
if (fileSettingsIDs.indexOf(s.identifier) > -1) {
let input = self.$refs[s.identifier][0]
let files = input.files
const input = self.$refs[s.identifier][0]
const files = input.files
console.log('ref', input, files)
if (files && files.length > 0) {
postData.append(s.identifier, files[0])
@ -139,8 +225,8 @@ export default {
}
axios.post('instance/admin/settings/bulk/', postData, {
headers: {
'Content-Type': contentType,
},
'Content-Type': contentType
}
}).then((response) => {
self.result = true
response.data.forEach((s) => {
@ -158,22 +244,6 @@ export default {
this.values = lodash.cloneDeep(this.values)
this.$set(this.values, key, value)
}
},
computed: {
settings () {
let byIdentifier = {}
this.settingsData.forEach(e => {
byIdentifier[e.identifier] = e
})
return this.group.settings.map(e => {
return {...byIdentifier[e.name], fieldType: e.fieldType, fieldParams: e.fieldParams || {}}
})
},
fileSettings () {
return this.settings.filter((s) => {
return s.field.widget.class === 'ImageWidget'
})
}
}
}
</script>

View File

@ -1,116 +1,173 @@
<template>
<div>
<div class="ui top attached tabular menu">
<button :class="[{active: !isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = false">
<translate translate-context="Content/*/Button.Label/Verb">Edit form</translate>
<button
:class="[{active: !isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = false"
>
<translate translate-context="Content/*/Button.Label/Verb">
Edit form
</translate>
</button>
<button :class="[{active: isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = true">
<translate translate-context="*/Form/Menu.item">Preview form</translate>
<button
:class="[{active: isPreviewing}, 'item']"
@click.stop.prevent="isPreviewing = true"
>
<translate translate-context="*/Form/Menu.item">
Preview form
</translate>
</button>
</div>
<div v-if="isPreviewing" class="ui bottom attached segment">
<div
v-if="isPreviewing"
class="ui bottom attached segment"
>
<signup-form
:customization="local"
:signup-approval-enabled="signupApprovalEnabled"
:fetch-description-html="true"></signup-form>
<div class="ui clearing hidden divider"></div>
:fetch-description-html="true"
/>
<div class="ui clearing hidden divider" />
</div>
<div v-else class="ui bottom attached segment">
<div
v-else
class="ui bottom attached segment"
>
<div class="field">
<label for="help-text">
<translate translate-context="*/*/Label">Help text</translate>
</label>
<p>
<translate translate-context="*/*/Help">An optional text to be displayed at the start of the sign-up form.</translate>
<translate translate-context="*/*/Help">
An optional text to be displayed at the start of the sign-up form.
</translate>
</p>
<content-form
field-id="help-text"
:permissive="true"
:value="(local.help_text || {}).text"
@input="update('help_text.text', $event)"></content-form>
@input="update('help_text.text', $event)"
/>
</div>
<div class="field">
<label>
<translate translate-context="*/*/Label">Additional fields</translate>
</label>
<p>
<translate translate-context="*/*/Help">Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled.</translate>
<translate translate-context="*/*/Help">
Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled.
</translate>
</p>
<table v-if="local.fields.length > 0">
<thead>
<tr>
<th>
<translate translate-context="*/*/Form-builder,Help">Field label</translate>
<translate translate-context="*/*/Form-builder,Help">
Field label
</translate>
</th>
<th>
<translate translate-context="*/*/Form-builder,Help">Field type</translate>
<translate translate-context="*/*/Form-builder,Help">
Field type
</translate>
</th>
<th>
<translate translate-context="*/*/Form-builder,Help">Required</translate>
<translate translate-context="*/*/Form-builder,Help">
Required
</translate>
</th>
<th><span class="visually-hidden"><translate translate-context="*/*/Form-builder,Help">Actions</translate></span></th>
</tr>
</thead>
<tbody>
<tr v-for="(field, idx) in local.fields">
<tr
v-for="(field, idx) in local.fields"
:key="idx"
>
<td>
<input type="text" v-model="field.label" required>
<input
v-model="field.label"
type="text"
required
>
</td>
<td>
<select v-model="field.input_type">
<option value="short_text">
<translate translate-context="*/*/Form-builder">Short text</translate>
<translate translate-context="*/*/Form-builder">
Short text
</translate>
</option>
<option value="long_text">
<translate translate-context="*/*/Form-builder">Long text</translate>
<translate translate-context="*/*/Form-builder">
Long text
</translate>
</option>
</select>
</td>
<td>
<select v-model="field.required">
<option :value="true">
<translate translate-context="*/*/*">Yes</translate>
<translate translate-context="*/*/*">
Yes
</translate>
</option>
<option :value="false">
<translate translate-context="*/*/*">No</translate>
<translate translate-context="*/*/*">
No
</translate>
</option>
</select>
</td>
<td>
<i
:disabled="idx === 0"
@click="move(idx, -1)" role="button"
role="button"
:title="labels.up"
:class="['up', 'arrow', {disabled: idx === 0}, 'icon']"></i>
:class="['up', 'arrow', {disabled: idx === 0}, 'icon']"
@click="move(idx, -1)"
/>
<i
:disabled="idx >= local.fields.length - 1"
@click="move(idx, 1)" role="button"
role="button"
:title="labels.down"
:class="['down', 'arrow', {disabled: idx >= local.fields.length - 1}, 'icon']"></i>
<i @click="remove(idx)" role="button" :title="labels.delete" class="x icon"></i>
:class="['down', 'arrow', {disabled: idx >= local.fields.length - 1}, 'icon']"
@click="move(idx, 1)"
/>
<i
role="button"
:title="labels.delete"
class="x icon"
@click="remove(idx)"
/>
</td>
</tr>
</tbody>
</table>
<div class="ui hidden divider"></div>
<button v-if="local.fields.length < maxFields" class="ui basic button" @click.stop.prevent="addField">
<translate translate-context="*/*/Form-builder">Add a new field</translate>
<div class="ui hidden divider" />
<button
v-if="local.fields.length < maxFields"
class="ui basic button"
@click.stop.prevent="addField"
>
<translate translate-context="*/*/Form-builder">
Add a new field
</translate>
</button>
</div>
</div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
</div>
</template>
<script>
import lodash from '@/lodash'
import SignupForm from "@/components/auth/SignupForm"
import SignupForm from '@/components/auth/SignupForm'
function arrayMove(arr, oldIndex, newIndex) {
function arrayMove (arr, oldIndex, newIndex) {
if (newIndex >= arr.length) {
var k = newIndex - arr.length + 1
let k = newIndex - arr.length + 1
while (k--) {
arr.push(undefined)
}
@ -122,40 +179,40 @@ function arrayMove(arr, oldIndex, newIndex) {
// v-model with objects is complex, cf
// https://simonkollross.de/posts/vuejs-using-v-model-with-objects-for-custom-components
export default {
props: {
value: {type: Object},
signupApprovalEnabled: {type: Boolean},
},
components: {
SignupForm
},
props: {
value: { type: Object, required: true },
signupApprovalEnabled: { type: Boolean }
},
data () {
return {
maxFields: 10,
isPreviewing: false
}
},
created () {
this.$emit('input', this.local)
},
computed: {
labels () {
return {
delete: this.$pgettext('*/*/*', 'Delete'),
up: this.$pgettext('*/*/*', 'Move up'),
down: this.$pgettext('*/*/*', 'Move down'),
down: this.$pgettext('*/*/*', 'Move down')
}
},
local() {
return (this.value && this.value.fields) ? this.value : { help_text: {text: null, content_type: "text/markdown"}, fields: [] }
},
local () {
return (this.value && this.value.fields) ? this.value : { help_text: { text: null, content_type: 'text/markdown' }, fields: [] }
}
},
created () {
this.$emit('input', this.local)
},
methods: {
addField () {
let newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({
const newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({
label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1),
required: true,
input_type: 'short_text',
input_type: 'short_text'
}))
this.$emit('input', newValue)
},
@ -169,10 +226,10 @@ export default {
if (idx + incr >= this.local.fields.length) {
return
}
let newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr)
const newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr)
this.update('fields', newFields)
},
update(key, value) {
update (key, value) {
if (key === 'help_text.text') {
key = 'help_text'
if (!value || value.length === 0) {
@ -180,12 +237,12 @@ export default {
} else {
value = {
text: value,
content_type: "text/markdown"
content_type: 'text/markdown'
}
}
}
this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => lodash.set(v, key, value)))
},
},
}
}
}
</script>

View File

@ -1,25 +1,34 @@
<template>
<router-link class="artist-label ui image label" :to="route">
<img alt="" :class="[{circular: artist.content_category != 'podcast'}]" v-if="artist.cover && artist.cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)" />
<i :class="[artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" v-else />
<router-link
class="artist-label ui image label"
:to="route"
>
<img
v-if="artist.cover && artist.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop)"
alt=""
:class="[{circular: artist.content_category != 'podcast'}]"
>
<i
v-else
:class="[artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']"
/>
{{ artist.name }}
</router-link>
</template>
<script>
import {momentFormat} from '@/filters'
export default {
props: {
artist: Object,
artist: { type: Object, required: true }
},
computed: {
route () {
if (this.artist.channel) {
return {name: 'channels.detail', params: {id: this.artist.channel.uuid}}
return { name: 'channels.detail', params: { id: this.artist.channel.uuid } }
}
return {name: 'library.artists.detail', params: {id: this.artist.id}}
return { name: 'library.artists.detail', params: { id: this.artist.id } }
}
}
}

View File

@ -1,67 +1,100 @@
<template>
<div class="card app-card">
<div
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]"
@click="$router.push({name: 'channels.detail', params: {id: urlId}})"
:class="['ui', 'head-image', {'circular': object.artist.content_category != 'podcast'}, {'padded': object.artist.content_category === 'podcast'}, 'image', {'default-cover': !object.artist.cover}]" v-lazy:background-image="imageUrl">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']" :artist="object.artist"></play-button>
>
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:artist="object.artist"
/>
</div>
<div class="content">
<strong>
<router-link class="discrete link" :to="{name: 'channels.detail', params: {id: urlId}}">
<router-link
class="discrete link"
:to="{name: 'channels.detail', params: {id: urlId}}"
>
{{ object.artist.name }}
</router-link>
</strong>
<div class="description">
<translate class="meta ellipsis" translate-context="Content/Channel/Paragraph"
key="1"
<translate
v-if="object.artist.content_category === 'podcast'"
key="1"
class="meta ellipsis"
translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="object.artist.tracks_count"
:translate-params="{count: object.artist.tracks_count}">
:translate-params="{count: object.artist.tracks_count}"
>
%{ count } episode
</translate>
<translate key="2" v-else translate-context="*/*/*" :translate-params="{count: object.artist.tracks_count}" :translate-n="object.artist.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate>
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
<translate
v-else
key="2"
translate-context="*/*/*"
:translate-params="{count: object.artist.tracks_count}"
:translate-n="object.artist.tracks_count"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate>
<tags-list
label-classes="tiny"
:truncate-size="20"
:limit="2"
:show-more="false"
:tags="object.artist.tags"
/>
</div>
</div>
<div class="extra content">
<time
v-translate
class="meta ellipsis"
:datetime="object.artist.modification_date"
:title="updatedTitle">
:title="updatedTitle"
>
%{ updatedAgo }
</time>
<play-button
class="right floated basic icon"
:dropdown-only="true"
:is-playable="true"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']" :artist="object.artist" :channel="object" :account="object.attributed_to"></play-button>
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="object.artist"
:channel="object"
:account="object.attributed_to"
/>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
import TagsList from '@/components/tags/List'
import {momentFormat} from '@/filters'
import moment from "moment"
import { momentFormat } from '@/filters'
import moment from 'moment'
export default {
props: {
object: {type: Object},
},
components: {
PlayButton,
TagsList
},
props: {
object: { type: Object, required: true }
},
computed: {
imageUrl () {
if (this.object.artist.cover) {
return this.$store.getters['instance/absoluteUrl'](this.object.artist.cover.urls.medium_square_crop)
}
return null
},
urlId () {
if (this.object.actor && this.object.actor.is_local) {
@ -73,9 +106,9 @@ export default {
}
},
updatedTitle () {
let d = momentFormat(this.object.artist.modification_date)
let message = this.$pgettext('*/*/*', 'Updated on %{ date }')
return this.$gettextInterpolate(message, {date: d})
const d = momentFormat(this.object.artist.modification_date)
const message = this.$pgettext('*/*/*', 'Updated on %{ date }')
return this.$gettextInterpolate(message, { date: d })
},
updatedAgo () {
return moment(this.object.artist.modification_date).fromNow()

View File

@ -1,9 +1,12 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<slot />
<div class="ui hidden divider" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<podcast-table
v-if="isPodcast"
@ -16,9 +19,10 @@
:show-album="false"
:paginate-results="true"
:total="count"
@page-changed="updatePage"
:page="page"
:paginate-by="limit"></podcast-table>
:paginate-by="limit"
@page-changed="updatePage"
/>
<track-table
v-else
:default-cover="defaultCover"
@ -30,13 +34,19 @@
:show-album="false"
:paginate-results="true"
:total="count"
@page-changed="updatePage"
:page="page"
:paginate-by="limit"></track-table>
:paginate-by="limit"
@page-changed="updatePage"
/>
<template v-if="!isLoading && objects.length === 0">
<empty-state @refresh="fetchData('tracks/')" :refresh="true">
<empty-state
:refresh="true"
@refresh="fetchData('tracks/')"
>
<p>
<translate translate-context="Content/Channels/*">You may need to subscribe to this channel to see its content.</translate>
<translate translate-context="Content/Channels/*">
You may need to subscribe to this channel to see its content.
</translate>
</p>
</empty-state>
</template>
@ -50,26 +60,31 @@ import PodcastTable from '@/components/audio/podcast/Table'
import TrackTable from '@/components/audio/track/Table'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 10},
defaultCover: {type: Object},
isPodcast: {type: Boolean, required: true},
},
components: {
PodcastTable,
TrackTable,
TrackTable
},
props: {
filters: { type: Object, required: true },
limit: { type: Number, default: 10 },
defaultCover: { type: Object, required: true },
isPodcast: { type: Boolean, required: true }
},
data () {
return {
objects: [],
count: 0,
isLoading: false,
errors: null,
errors: [],
nextPage: null,
page: 1
}
},
watch: {
page () {
this.fetchData('tracks/')
}
},
created () {
this.fetchData('tracks/')
},
@ -79,31 +94,26 @@ export default {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
const self = this
const params = _.clone(this.filters)
params.page_size = this.limit
params.page = this.page
params.include_channels = true
try {
let channelsPromise = await axios.get(url, {params: params})
self.nextPage = channelsPromise.data.next
self.objects = channelsPromise.data.results
self.count = channelsPromise.data.count
self.$emit('fetched', channelsPromise.data)
self.isLoading = false
} catch(e) {
const channelsPromise = await axios.get(url, { params: params })
self.nextPage = channelsPromise.data.next
self.objects = channelsPromise.data.results
self.count = channelsPromise.data.count
self.$emit('fetched', channelsPromise.data)
self.isLoading = false
self.errors = error.backendErrors
} catch (e) {
self.isLoading = false
self.errors = e.backendErrors
}
},
updatePage: function(page) {
updatePage: function (page) {
this.page = page
}
},
watch: {
page() {
this.fetchData('tracks/')
}
}
}
</script>

View File

@ -1,48 +1,77 @@
<template>
<div :class="[{active: currentTrack && isPlaying && entry.id === currentTrack.id}, 'channel-entry-card']">
<div class="controls">
<play-button class="basic circular icon" :discrete="true" :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'inverted vibrant', 'icon', 'button']" :track="entry"></play-button>
<play-button
class="basic circular icon"
:discrete="true"
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'inverted vibrant', 'icon', 'button']"
:track="entry"
/>
</div>
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
alt=""
class="channel-image image"
v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)">
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else-if="entry.artist.content_category === 'podcast' && defaultCover != undefined"
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)">
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
alt=""
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else-if="entry.album && entry.album.cover && entry.album.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)">
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.urls.medium_square_crop)"
alt=""
class="channel-image image"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<img
v-else
src="../../assets/audio/default-cover.png">
alt=""
class="channel-image image"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
>
<div class="ellipsis content">
<strong>
<router-link class="discrete link" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
<router-link
class="discrete link"
:to="{name: 'library.tracks.detail', params: {id: entry.id}}"
>
{{ entry.title }}
</router-link>
</strong>
<br>
<human-date class="really discrete" :date="entry.creation_date"></human-date>
<human-date
class="really discrete"
:date="entry.creation_date"
/>
</div>
<div class="meta">
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
<track-favorite-icon class="tiny" :track="entry"></track-favorite-icon>
</template>
<human-duration v-if="duration" :duration="duration"></human-duration>
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
<track-favorite-icon
class="tiny"
:track="entry"
/>
</template>
<human-duration
v-if="duration"
:duration="duration"
/>
</div>
<div class="controls">
<play-button class="play-button basic icon" :dropdown-only="true" :is-playable="entry.is_playable" :dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']" :track="entry"></play-button>
<play-button
class="play-button basic icon"
:dropdown-only="true"
:is-playable="entry.is_playable"
:dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']"
:track="entry"
/>
</div>
</div>
</template>
@ -50,19 +79,21 @@
<script>
import PlayButton from '@/components/audio/PlayButton'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import { mapGetters } from "vuex"
import { mapGetters } from 'vuex'
export default {
props: ['entry', 'defaultCover'],
components: {
PlayButton,
TrackFavoriteIcon,
TrackFavoriteIcon
},
props: {
entry: { type: Object, required: true },
defaultCover: { type: Object, required: true }
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
currentTrack: 'queue/currentTrack'
}),
isPlaying () {
@ -72,14 +103,16 @@ export default {
if (this.entry.cover) {
return this.entry.cover
}
return null
},
duration () {
let uploads = this.entry.uploads.filter((e) => {
const uploads = this.entry.uploads.filter((e) => {
return e.duration
})
if (uploads.length > 0) {
return uploads[0].duration
}
return null
}
}
}

View File

@ -1,24 +1,55 @@
<template>
<form class="ui form" @submit.prevent.stop="submit">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving channel</translate></h4>
<form
class="ui form"
@submit.prevent.stop="submit"
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while saving channel
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<template v-if="metadataChoices">
<fieldset v-if="creating && step === 1" class="ui grouped channel-type required field">
<fieldset
v-if="creating && step === 1"
class="ui grouped channel-type required field"
>
<legend>
<translate translate-context="Content/Channel/Paragraph">What will this channel be used for?</translate>
<translate translate-context="Content/Channel/Paragraph">
What will this channel be used for?
</translate>
</legend>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<div class="field">
<div :class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]" v-for="choice in categoryChoices">
<input type="radio" name="channel-category" :id="`category-${choice.value}`" :value="choice.value" v-model="newValues.content_category">
<div
v-for="(choice, key) in categoryChoices"
:key="key"
:class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]"
>
<input
:id="`category-${choice.value}`"
v-model="newValues.content_category"
type="radio"
name="channel-category"
:value="choice.value"
>
<label :for="`category-${choice.value}`">
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]"></span>
<span :class="['right floated', 'placeholder', 'image', {circular: choice.value === 'music'}]" />
<strong>{{ choice.label }}</strong>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
{{ choice.helpText }}
</label>
</div>
@ -29,20 +60,35 @@
<label for="channel-name">
<translate translate-context="Content/Channel/*">Name</translate>
</label>
<input type="text" required v-model="newValues.name" :placeholder="labels.namePlaceholder">
<input
v-model="newValues.name"
type="text"
required
:placeholder="labels.namePlaceholder"
>
</div>
<div class="ui required field">
<label for="channel-username">
<translate translate-context="Content/Channel/*">Fediverse handle</translate>
</label>
<div class="ui left labeled input">
<div class="ui basic label">@</div>
<input type="text" :required="creating" :disabled="!creating" :placeholder="labels.usernamePlaceholder" v-model="newValues.username">
<div class="ui basic label">
@
</div>
<input
v-model="newValues.username"
type="text"
:required="creating"
:disabled="!creating"
:placeholder="labels.usernamePlaceholder"
>
</div>
<template v-if="creating">
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<p>
<translate translate-context="Content/Channels/Paragraph">Used in URLs and to follow this channel in the Fediverse. It cannot be changed later.</translate>
<translate translate-context="Content/Channels/Paragraph">
Used in URLs and to follow this channel in the Fediverse. It cannot be changed later.
</translate>
</p>
</template>
</div>
@ -51,12 +97,17 @@
v-model="newValues.cover"
:required="false"
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
@delete="newValues.cover = null">
<translate translate-context="Content/Channel/*" slot="label">Channel Picture</translate>
@delete="newValues.cover = null"
>
<translate
slot="label"
translate-context="Content/Channel/*"
>
Channel Picture
</translate>
</attachment-input>
</div>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<div class="ui stackable grid row">
<div class="ten wide column">
<div class="ui field">
@ -64,46 +115,67 @@
<translate translate-context="*/*/*">Tags</translate>
</label>
<tags-selector
v-model="newValues.tags"
id="channel-tags"
:required="false"></tags-selector>
v-model="newValues.tags"
:required="false"
/>
</div>
</div>
<div class="six wide column" v-if="newValues.content_category === 'podcast'">
<div
v-if="newValues.content_category === 'podcast'"
class="six wide column"
>
<div class="ui required field">
<label for="channel-language">
<translate translate-context="*/*/*">Language</translate>
</label>
<select
name="channel-language"
id="channel-language"
v-model="newValues.metadata.language"
name="channel-language"
required
class="ui search selection dropdown">
<option v-for="v in metadataChoices.language" :value="v.value">{{ v.label }}</option>
class="ui search selection dropdown"
>
<option
v-for="(v, key) in metadataChoices.language"
:key="key"
:value="v.value"
>
{{ v.label }}
</option>
</select>
</div>
</div>
</div>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<div class="ui field">
<label for="channel-name">
<translate translate-context="*/*/*">Description</translate>
</label>
<content-form v-model="newValues.description"></content-form>
<content-form v-model="newValues.description" />
</div>
<div class="ui two fields" v-if="newValues.content_category === 'podcast'">
<div
v-if="newValues.content_category === 'podcast'"
class="ui two fields"
>
<div class="ui required field">
<label for="channel-itunes-category">
<translate translate-context="*/*/*">Category</translate>
</label>
<select
name="itunes-category"
id="itunes-category"
v-model="newValues.metadata.itunes_category"
name="itunes-category"
required
class="ui dropdown">
<option v-for="v in metadataChoices.itunes_category" :value="v.value">{{ v.label }}</option>
class="ui dropdown"
>
<option
v-for="(v, key) in metadataChoices.itunes_category"
:key="key"
:value="v.value"
>
{{ v.label }}
</option>
</select>
</div>
<div class="ui field">
@ -111,45 +183,64 @@
<translate translate-context="*/*/*">Subcategory</translate>
</label>
<select
name="itunes-category"
id="itunes-category"
v-model="newValues.metadata.itunes_subcategory"
name="itunes-category"
:disabled="!newValues.metadata.itunes_category"
class="ui dropdown">
<option v-for="v in itunesSubcategories" :value="v">{{ v }}</option>
class="ui dropdown"
>
<option
v-for="(v, key) in itunesSubcategories"
:key="key"
:value="v"
>
{{ v }}
</option>
</select>
</div>
</div>
<div class="ui two fields" v-if="newValues.content_category === 'podcast'">
<div
v-if="newValues.content_category === 'podcast'"
class="ui two fields"
>
<div class="ui field">
<label for="channel-itunes-email">
<translate translate-context="*/*/*">Owner e-mail address</translate>
</label>
<input
name="channel-itunes-email"
id="channel-itunes-email"
v-model="newValues.metadata.owner_email"
name="channel-itunes-email"
type="email"
v-model="newValues.metadata.owner_email">
>
</div>
<div class="ui field">
<label for="channel-itunes-name">
<translate translate-context="*/*/*">Owner name</translate>
</label>
<input
name="channel-itunes-name"
id="channel-itunes-name"
v-model="newValues.metadata.owner_name"
name="channel-itunes-name"
maxlength="255"
v-model="newValues.metadata.owner_name">
>
</div>
</div>
<p>
<translate translate-context="*/*/*">Used for the itunes:email and itunes:name field required by certain platforms such as Spotify or iTunes.</translate>
<translate translate-context="*/*/*">
Used for the itunes:email and itunes:name field required by certain platforms such as Spotify or iTunes.
</translate>
</p>
</template>
</template>
<div v-else class="ui active inverted dimmer">
<div
v-else
class="ui active inverted dimmer"
>
<div class="ui text loader">
<translate translate-context="*/*/*">Loading</translate>
<translate translate-context="*/*/*">
Loading
</translate>
</div>
</div>
</form>
@ -161,29 +252,25 @@ import axios from 'axios'
import AttachmentInput from '@/components/common/AttachmentInput'
import TagsSelector from '@/components/library/TagsSelector'
function slugify(text) {
function slugify (text) {
return text.toString().toLowerCase()
.replace(/\s+/g, '') // Remove spaces
.replace(/[^\w]+/g, '') // Remove all non-word chars
.replace(/\s+/g, '') // Remove spaces
.replace(/[^\w]+/g, '') // Remove all non-word chars
}
export default {
props: {
object: {type: Object, required: false, default: null},
step: {type: Number, required: false, default: 1},
},
components: {
AttachmentInput,
TagsSelector
},
created () {
this.fetchMetadataChoices()
props: {
object: { type: Object, required: false, default: null },
step: { type: Number, required: false, default: 1 }
},
data () {
let oldValues = {}
const oldValues = {}
if (this.object) {
oldValues.metadata = {...(this.object.metadata || {})}
oldValues.metadata = { ...(this.object.metadata || {}) }
oldValues.name = this.object.artist.name
oldValues.description = this.object.artist.description
oldValues.cover = this.object.artist.cover
@ -196,13 +283,13 @@ export default {
errors: [],
metadataChoices: null,
newValues: {
name: oldValues.name || "",
username: oldValues.username || "",
name: oldValues.name || '',
username: oldValues.username || '',
tags: oldValues.tags || [],
description: (oldValues.description || {}).text || "",
description: (oldValues.description || {}).text || '',
cover: (oldValues.cover || {}).uuid || null,
content_category: oldValues.content_category || "podcast",
metadata: oldValues.metadata || {},
content_category: oldValues.content_category || 'podcast',
metadata: oldValues.metadata || {}
}
}
},
@ -213,20 +300,20 @@ export default {
categoryChoices () {
return [
{
value: "podcast",
label: this.$pgettext('*/*/*', "Podcasts"),
helpText: this.$pgettext('Content/Channels/Help', "Host your episodes and keep your community updated."),
value: 'podcast',
label: this.$pgettext('*/*/*', 'Podcasts'),
helpText: this.$pgettext('Content/Channels/Help', 'Host your episodes and keep your community updated.')
},
{
value: "music",
label: this.$pgettext('*/*/*', "Artist discography"),
helpText: this.$pgettext('Content/Channels/Help', "Publish music you make as a nice discography of albums and singles."),
value: 'music',
label: this.$pgettext('*/*/*', 'Artist discography'),
helpText: this.$pgettext('Content/Channels/Help', 'Publish music you make as a nice discography of albums and singles.')
}
]
},
itunesSubcategories () {
for (let index = 0; index < this.metadataChoices.itunes_category.length; index++) {
const element = this.metadataChoices.itunes_category[index];
const element = this.metadataChoices.itunes_category[index]
if (element.value === this.newValues.metadata.itunes_category) {
return element.children || []
}
@ -235,8 +322,8 @@ export default {
},
labels () {
return {
namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "Awesome channel name"),
usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', "awesomechannelname"),
namePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', 'Awesome channel name'),
usernamePlaceholder: this.$pgettext('Content/Channel/Form.Field.Placeholder', 'awesomechannelname')
}
},
submittable () {
@ -247,9 +334,41 @@ export default {
return !!v
}
},
watch: {
'newValues.name' (v) {
if (this.creating) {
this.newValues.username = slugify(v)
}
},
'newValues.metadata.itunes_category' (v) {
this.newValues.metadata.itunes_subcategory = null
},
'newValues.content_category': {
handler (v) {
this.$emit('category', v)
},
immediate: true
},
isLoading: {
handler (v) {
this.$emit('loading', v)
},
immediate: true
},
submittable: {
handler (v) {
this.$emit('submittable', v)
},
immediate: true
}
},
created () {
this.fetchMetadataChoices()
},
methods: {
fetchMetadataChoices () {
let self = this
const self = this
axios.get('channels/metadata-choices').then((response) => {
self.metadataChoices = response.data
}, error => {
@ -258,21 +377,21 @@ export default {
},
submit () {
this.isLoading = true
let self = this
let handler = this.creating ? axios.post : axios.patch
let url = this.creating ? `channels/` : `channels/${this.object.uuid}`
let payload = {
const self = this
const handler = this.creating ? axios.post : axios.patch
const url = this.creating ? 'channels/' : `channels/${this.object.uuid}`
const payload = {
name: this.newValues.name,
username: this.newValues.username,
tags: this.newValues.tags,
content_category: this.newValues.content_category,
cover: this.newValues.cover,
metadata: this.newValues.metadata,
metadata: this.newValues.metadata
}
if (this.newValues.description) {
payload.description = {
content_type: 'text/markdown',
text: this.newValues.description,
text: this.newValues.description
}
} else {
payload.description = null
@ -291,34 +410,6 @@ export default {
self.$emit('errored', self.errors)
})
}
},
watch: {
"newValues.name" (v) {
if (this.creating) {
this.newValues.username = slugify(v)
}
},
"newValues.metadata.itunes_category" (v) {
this.newValues.metadata.itunes_subcategory = null
},
"newValues.content_category": {
handler (v) {
this.$emit("category", v)
},
immediate: true
},
isLoading: {
handler (v) {
this.$emit("loading", v)
},
immediate: true
},
submittable: {
handler (v) {
this.$emit("submittable", v)
},
immediate: true
},
}
}
</script>

View File

@ -1,28 +1,62 @@
<template>
<div class="channel-serie-card">
<div class="two-images">
<img alt="" @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover && cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)">
<img alt="" @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img alt="" @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover && cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)">
<img alt="" @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img
v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-if="cover && cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)"
alt=""
class="channel-image"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
@click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})"
>
</div>
<div class="content ellipsis">
<strong>
<router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: serie.id}}">
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: serie.id}}"
>
{{ serie.title }}
</router-link>
</strong>
<div class="description">
<translate translate-context="Content/Channel/Paragraph"
<translate
translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="serie.tracks_count"
:translate-params="{count: serie.tracks_count}">
:translate-params="{count: serie.tracks_count}"
>
%{ count } episode
</translate>
</div>
</div>
<div class="controls">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']" :album="serie"></play-button>
<play-button
:icon-only="true"
:is-playable="true"
:button-classes="['ui', 'circular', 'vibrant', 'icon', 'button']"
:album="serie"
/>
</div>
</div>
</template>
@ -31,18 +65,19 @@
import PlayButton from '@/components/audio/PlayButton'
export default {
props: ['serie'],
components: {
PlayButton,
PlayButton
},
props: { serie: { type: Object, required: true } },
computed: {
cover () {
if (this.serie.cover) {
return this.serie.cover
}
return null
},
duration () {
let uploads = this.serie.uploads.filter((e) => {
const uploads = this.serie.uploads.filter((e) => {
return e.duration
})
return uploads[0].duration

View File

@ -1,26 +1,51 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<slot />
<div class="ui hidden divider" />
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<template v-if="isPodcast">
<channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
<channel-serie-card
v-for="serie in objects"
:key="serie.id"
:serie="serie"
/>
</template>
<div v-else class="ui app-cards cards">
<album-card v-for="album in objects" :album="album" :key="album.id" />
<div
v-else
class="ui app-cards cards"
>
<album-card
v-for="album in objects"
:key="album.id"
:album="album"
/>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
<template v-if="!isLoading && objects.length === 0">
<empty-state @refresh="fetchData('albums/')" :refresh="true">
<empty-state
:refresh="true"
@refresh="fetchData('albums/')"
>
<p>
<translate translate-context="Content/Channels/*">You may need to subscribe to this channel to see its contents.</translate>
<translate translate-context="Content/Channels/*">
You may need to subscribe to this channel to see its contents.
</translate>
</p>
</empty-state>
</template>
@ -33,16 +58,15 @@ import axios from 'axios'
import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
import AlbumCard from '@/components/audio/album/Card'
export default {
props: {
filters: {type: Object, required: true},
isPodcast: {type: Boolean, default: true},
limit: {type: Number, default: 5},
},
components: {
ChannelSerieCard,
AlbumCard,
AlbumCard
},
props: {
filters: { type: Object, required: true },
isPodcast: { type: Boolean, default: true },
limit: { type: Number, default: 5 }
},
data () {
return {
@ -62,11 +86,11 @@ export default {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
const self = this
const params = _.clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.nextPage = response.data.next
self.isLoading = false
self.objects = self.objects.concat(response.data.results)
@ -75,7 +99,7 @@ export default {
self.isLoading = false
self.errors = error.backendErrors
})
},
}
}
}
</script>

View File

@ -1,21 +1,37 @@
<template>
<div>
<slot></slot>
<div class="ui hidden divider"></div>
<slot />
<div class="ui hidden divider" />
<div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<channel-card v-for="object in objects" :object="object" :key="object.uuid" />
<channel-card
v-for="object in objects"
:key="object.uuid"
:object="object"
/>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
<template v-if="!isLoading && objects.length === 0">
<empty-state @refresh="fetchData('channels/')" :refresh="true"></empty-state>
<empty-state
:refresh="true"
@refresh="fetchData('channels/')"
/>
</template>
</div>
</template>
@ -26,13 +42,13 @@ import axios from 'axios'
import ChannelCard from '@/components/audio/ChannelCard'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 5},
},
components: {
ChannelCard
},
props: {
filters: { type: Object, required: true },
limit: { type: Number, default: 5 }
},
data () {
return {
objects: [],
@ -51,11 +67,11 @@ export default {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
const self = this
const params = _.clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.nextPage = response.data.next
self.isLoading = false
self.objects = self.objects.concat(response.data.results)
@ -65,7 +81,7 @@ export default {
self.isLoading = false
self.errors = error.backendErrors
})
},
}
}
}
</script>

View File

@ -1,13 +1,19 @@
<template>
<div>
<div role="alert" class="ui warning message" v-if="!anonymousCanListen">
<div
v-if="!anonymousCanListen"
role="alert"
class="ui warning message"
>
<p>
<strong>
<translate translate-context="Content/Embed/Message">Sharing will not work because this pod doesn't allow anonymous users to access content.</translate>
</strong>
</p>
<p>
<translate translate-context="Content/Embed/Message">Please contact your admins and ask them to update the corresponding setting.</translate>
<translate translate-context="Content/Embed/Message">
Please contact your admins and ask them to update the corresponding setting.
</translate>
</p>
</div>
<div class="ui form">
@ -15,49 +21,100 @@
<div class="field">
<div class="field">
<label for="embed-width"><translate translate-context="Popup/Embed/Input.Label">Widget width</translate></label>
<p><translate translate-context="Popup/Embed/Paragraph">Leave empty for a responsive widget</translate></p>
<input id="embed-width" type="number" v-model.number="width" min="0" step="10" />
<p>
<translate translate-context="Popup/Embed/Paragraph">
Leave empty for a responsive widget
</translate>
</p>
<input
id="embed-width"
v-model.number="width"
type="number"
min="0"
step="10"
>
</div>
<template v-if="type != 'track'">
<br>
<div class="field">
<label for="embed-height"><translate translate-context="Popup/Embed/Input.Label">Widget height</translate></label>
<input id="embed-height" type="number" v-model="height" :min="minHeight" max="1000" step="10" />
<input
id="embed-height"
v-model="height"
type="number"
:min="minHeight"
max="1000"
step="10"
>
</div>
</template>
</div>
<div class="field">
<button @click="copy" class="ui right accent labeled icon floated button"><i class="copy icon"></i><translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate></button>
<button
class="ui right accent labeled icon floated button"
@click="copy"
>
<i class="copy icon" /><translate translate-context="*/*/Button.Label/Short, Verb">
Copy
</translate>
</button>
<label for="embed-width"><translate translate-context="Popup/Embed/Input.Label/Noun">Embed code</translate></label>
<p><translate translate-context="Popup/Embed/Paragraph">Copy/paste this code in your website HTML</translate></p>
<textarea ref="textarea" :value="embedCode" rows="5" readonly>
</textarea>
<p>
<translate translate-context="Popup/Embed/Paragraph">
Copy/paste this code in your website HTML
</translate>
</p>
<textarea
ref="textarea"
:value="embedCode"
rows="5"
readonly
/>
<div class="ui right">
<p class="message" v-if=copied><translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate></p>
<p
v-if="copied"
class="message"
>
<translate translate-context="Content/*/Paragraph">
Text copied to clipboard!
</translate>
</p>
</div>
</div>
</div>
</div>
<div class="preview">
<h3>
<a :href="iframeSrc" target="_blank">
<a
:href="iframeSrc"
target="_blank"
>
<translate translate-context="Popup/Embed/Title/Noun">Preview</translate>
</a>
</h3>
<iframe :width="frameWidth" :height="height" scrolling="no" frameborder="no" :src="iframeSrc"></iframe>
<iframe
:width="frameWidth"
:height="height"
scrolling="no"
frameborder="no"
:src="iframeSrc"
/>
</div>
</div>
</template>
<script>
import { mapState } from "vuex"
import { mapState } from 'vuex'
import _ from '@/lodash'
export default {
props: ['type', 'id'],
props: {
type: { type: String, required: true },
id: { type: Number, required: true }
},
data () {
let d = {
const d = {
width: null,
height: 150,
minHeight: 100,
@ -71,7 +128,7 @@ export default {
},
computed: {
...mapState({
nodeinfo: state => state.instance.nodeinfo,
nodeinfo: state => state.instance.nodeinfo
}),
anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen', false)
@ -82,7 +139,7 @@ export default {
// include hostname/protocol too so that the iframe link is absolute
base = `${window.location.protocol}//${window.location.host}${base}`
}
let instanceUrl = this.$store.state.instance.instanceUrl
const instanceUrl = this.$store.state.instance.instanceUrl
let b = ''
if (!window.location.href.startsWith(instanceUrl)) {
// the frontend is running on a separate domain, so we need to provide
@ -98,15 +155,15 @@ export default {
return '100%'
},
embedCode () {
let src = this.iframeSrc.replace(/&/g, '&amp;')
const src = this.iframeSrc.replace(/&/g, '&amp;')
return `<iframe width="${this.frameWidth}" height="${this.height}" scrolling="no" frameborder="no" src="${src}"></iframe>`
}
},
methods: {
copy () {
this.$refs.textarea.select()
document.execCommand("Copy")
let self = this
document.execCommand('Copy')
const self = this
self.copied = true
this.timeout = setTimeout(() => {
self.copied = false

View File

@ -1,16 +1,34 @@
<template>
<button @click.stop="toggle" :class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isApproved" translate-context="Content/Library/Card.Button.Label/Verb">Unfollow</translate>
<translate v-else-if="isPending" translate-context="Content/Library/Card.Button.Label/Verb">Cancel follow request</translate>
<translate v-else translate-context="Content/Library/Card.Button.Label/Verb">Follow</translate>
<template>
<button
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
@click.stop="toggle"
>
<i class="heart icon" />
<translate
v-if="isApproved"
translate-context="Content/Library/Card.Button.Label/Verb"
>
Unfollow
</translate>
<translate
v-else-if="isPending"
translate-context="Content/Library/Card.Button.Label/Verb"
>
Cancel follow request
</translate>
<translate
v-else
translate-context="Content/Library/Card.Button.Label/Verb"
>
Follow
</translate>
</button>
</template>
<script>
export default {
props: {
library: {type: Object},
library: { type: Object, required: true }
},
computed: {
isPending () {
@ -34,6 +52,5 @@ export default {
}
}
}
</script>

View File

@ -1,52 +1,121 @@
<template>
<span :title="title" :class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']">
<span
:title="title"
:class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
>
<button
v-if="!dropdownOnly"
@click.stop.prevent="replacePlay"
:disabled="!playable"
:aria-label="labels.replacePlay"
:class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])">
<i v-if="playing" class="pause icon"></i>
<i v-else :class="[playIconClass, 'icon']"></i>
:class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])"
@click.stop.prevent="replacePlay"
>
<i
v-if="playing"
class="pause icon"
/>
<i
v-else
:class="[playIconClass, 'icon']"
/>
<template v-if="!discrete && !iconOnly">&nbsp;<slot><translate translate-context="*/Queue/Button.Label/Short, Verb">Play</translate></slot></template>
</button>
<button
v-if="!discrete && !iconOnly"
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"
@click.stop.prevent="clicked = true"
:class="['ui', {disabled: !playable && !filterableArtist}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]">
<i :class="dropdownIconClasses.concat(['icon'])" :title="title" ></i>
<div class="menu" v-if="clicked">
<button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue">
<i class="plus icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
>
<i
:class="dropdownIconClasses.concat(['icon'])"
:title="title"
/>
<div
v-if="clicked"
class="menu"
>
<button
ref="add"
class="item basic"
data-ref="add"
:disabled="!playable"
:title="labels.addToQueue"
@click.stop.prevent="add"
>
<i class="plus icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
</button>
<button class="item basic" ref="addNext" data-ref="addNext" :disabled="!playable" @click.stop.prevent="addNext()" :title="labels.playNext">
<i class="step forward icon"></i>{{ labels.playNext }}
<button
ref="addNext"
class="item basic"
data-ref="addNext"
:disabled="!playable"
:title="labels.playNext"
@click.stop.prevent="addNext()"
>
<i class="step forward icon" />{{ labels.playNext }}
</button>
<button class="item basic" ref="playNow" data-ref="playNow" :disabled="!playable" @click.stop.prevent="addNext(true)" :title="labels.playNow">
<i class="play icon"></i>{{ labels.playNow }}
<button
ref="playNow"
class="item basic"
data-ref="playNow"
:disabled="!playable"
:title="labels.playNow"
@click.stop.prevent="addNext(true)"
>
<i class="play icon" />{{ labels.playNow }}
</button>
<button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio">
<i class="feed icon"></i><translate translate-context="*/Queue/Button.Label/Short, Verb">Play radio</translate>
<button
v-if="track"
class="item basic"
:disabled="!playable"
:title="labels.startRadio"
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})"
>
<i class="feed icon" /><translate translate-context="*/Queue/Button.Label/Short, Verb">Play radio</translate>
</button>
<button v-if="track" class="item basic" :disabled="!playable" @click.stop="$store.commit('playlists/chooseTrack', track)">
<i class="list icon"></i>
<button
v-if="track"
class="item basic"
:disabled="!playable"
@click.stop="$store.commit('playlists/chooseTrack', track)"
>
<i class="list icon" />
<translate translate-context="Sidebar/Player/Icon.Tooltip/Verb">Add to playlist</translate>
</button>
<button v-if="track" class="item basic" @click.stop.prevent="$router.push(`/library/tracks/${track.id}/`)">
<i class="info icon"></i>
<translate v-if="track.artist.content_category === 'podcast'" translate-context="*/Queue/Dropdown/Button/Label/Short">Episode details</translate>
<translate v-else translate-context="*/Queue/Dropdown/Button/Label/Short">Track details</translate>
<button
v-if="track"
class="item basic"
@click.stop.prevent="$router.push(`/library/tracks/${track.id}/`)"
>
<i class="info icon" />
<translate
v-if="track.artist.content_category === 'podcast'"
translate-context="*/Queue/Dropdown/Button/Label/Short"
>Episode details</translate>
<translate
v-else
translate-context="*/Queue/Dropdown/Button/Label/Short"
>Track details</translate>
</button>
<div class="divider"></div>
<button v-if="filterableArtist" ref="filterArtist" data-ref="filterArtist" class="item basic" :disabled="!filterableArtist" @click.stop.prevent="filterArtist" :title="labels.hideArtist">
<i class="eye slash outline icon"></i><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
<div class="divider" />
<button
v-if="filterableArtist"
ref="filterArtist"
data-ref="filterArtist"
class="item basic"
:disabled="!filterableArtist"
:title="labels.hideArtist"
@click.stop.prevent="filterArtist"
>
<i class="eye slash outline icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
</button>
<button
v-for="obj in getReportableObjs({track, album, artist, playlist, account, channel})"
:key="obj.target.type + obj.target.id"
:ref="`report${obj.target.type}${obj.target.id}`"
class="item basic"
:ref="`report${obj.target.type}${obj.target.id}`" :data-ref="`report${obj.target.type}${obj.target.id}`"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
:data-ref="`report${obj.target.type}${obj.target.id}`"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
>
<i class="share icon" /> {{ obj.label }}
</button>
</div>
@ -55,7 +124,6 @@
</template>
<script>
import axios from 'axios'
import jQuery from 'jquery'
import ReportMixin from '@/components/mixins/Report'
@ -65,23 +133,23 @@ export default {
mixins: [ReportMixin, PlayOptionsMixin],
props: {
// we can either have a single or multiple tracks to play when clicked
tracks: {type: Array, required: false},
track: {type: Object, required: false},
account: {type: Object, required: false},
dropdownIconClasses: {type: Array, required: false, default: () => { return ['dropdown'] }},
playIconClass: {type: String, required: false, default: 'play icon'},
buttonClasses: {type: Array, required: false, default: () => { return ['button'] }},
playlist: {type: Object, required: false},
discrete: {type: Boolean, default: false},
dropdownOnly: {type: Boolean, default: false},
iconOnly: {type: Boolean, default: false},
artist: {type: Object, required: false},
album: {type: Object, required: false},
library: {type: Object, required: false},
channel: {type: Object, required: false},
isPlayable: {type: Boolean, required: false, default: null},
playing: {type: Boolean, required: false, default: false},
paused: {type: Boolean, required: false, default: false}
tracks: { type: Array, required: false, default: () => { return [] } },
track: { type: Object, required: false, default: () => { return {} } },
account: { type: Object, required: false, default: () => { return {} } },
dropdownIconClasses: { type: Array, required: false, default: () => { return ['dropdown'] } },
playIconClass: { type: String, required: false, default: 'play icon' },
buttonClasses: { type: Array, required: false, default: () => { return ['button'] } },
playlist: { type: Object, required: false, default: () => { return {} } },
discrete: { type: Boolean, default: false },
dropdownOnly: { type: Boolean, default: false },
iconOnly: { type: Boolean, default: false },
artist: { type: Object, required: false, default: () => { return {} } },
album: { type: Object, required: false, default: () => { return {} } },
library: { type: Object, required: false, default: () => { return {} } },
channel: { type: Object, required: false, default: () => { return {} } },
isPlayable: { type: Boolean, required: false, default: null },
playing: { type: Boolean, required: false, default: false },
paused: { type: Boolean, required: false, default: false }
},
data () {
return {
@ -111,7 +179,7 @@ export default {
startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'),
report: this.$pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'),
addToPlaylist: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…'),
replacePlay,
replacePlay
}
},
title () {
@ -122,43 +190,42 @@ export default {
return this.$pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to')
}
}
},
return null
}
},
watch: {
clicked () {
let self = this
const self = this
this.$nextTick(() => {
jQuery(this.$el).find('.ui.dropdown').dropdown({
selectOnKeydown: false,
action: function (text, value, $el) {
// used to ensure focusing the dropdown and clicking via keyboard
// works as expected
let button = self.$refs[$el.data('ref')]
const button = self.$refs[$el.data('ref')]
if (Array.isArray(button)) {
button[0].click()
} else {
button.click()
}
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
},
}
})
jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () {
// little magic to ensure the menu is always visible in the viewport
// By default, try to diplay it on the right if there is enough room
let menu = jQuery(self.$el).find('.ui.dropdown').find(".menu")
let viewportOffset = menu.get(0).getBoundingClientRect();
let left = viewportOffset.left;
let viewportWidth = document.documentElement.clientWidth
let rightOverflow = viewportOffset.right - viewportWidth
let leftOverflow = -viewportOffset.left
const menu = jQuery(self.$el).find('.ui.dropdown').find('.menu')
const viewportOffset = menu.get(0).getBoundingClientRect()
const viewportWidth = document.documentElement.clientWidth
const rightOverflow = viewportOffset.right - viewportWidth
const leftOverflow = -viewportOffset.left
let offset = 0
if (rightOverflow > 0) {
offset = -rightOverflow - 5
menu.css({cssText: `left: ${offset}px !important;`});
}
else if (leftOverflow > 0) {
offset = leftOverflow + 5
menu.css({cssText: `right: -${offset}px !important;`});
menu.css({ cssText: `left: ${offset}px !important;` })
} else if (leftOverflow > 0) {
offset = leftOverflow + 5
menu.css({ cssText: `right: -${offset}px !important;` })
}
})
})

View File

@ -1,64 +1,144 @@
<template>
<section role="complementary" v-if="currentTrack" class="player-wrapper ui bottom-player component-player" aria-labelledby="player-label">
<h1 id="player-label" class="visually-hidden">
<translate translate-context="*/*/*">Audio player and controls</translate>
<section
v-if="currentTrack"
role="complementary"
class="player-wrapper ui bottom-player component-player"
aria-labelledby="player-label"
>
<h1
id="player-label"
class="visually-hidden"
>
<translate translate-context="*/*/*">
Audio player and controls
</translate>
</h1>
<div class="ui inverted segment fixed-controls" @click.prevent.stop="toggleMobilePlayer">
<div
class="ui inverted segment fixed-controls"
@click.prevent.stop="toggleMobilePlayer"
>
<div
:class="['ui', 'top attached', 'small', 'inverted', {'indicating': isLoadingAudio}, 'progress']">
<div class="buffer bar" :data-percent="bufferProgress" :style="{ 'width': bufferProgress + '%' }"></div>
<div class="position bar" :data-percent="progress" :style="{ 'width': progress + '%' }"></div>
:class="['ui', 'top attached', 'small', 'inverted', {'indicating': isLoadingAudio}, 'progress']"
>
<div
class="buffer bar"
:data-percent="bufferProgress"
:style="{ 'width': bufferProgress + '%' }"
/>
<div
class="position bar"
:data-percent="progress"
:style="{ 'width': progress + '%' }"
/>
</div>
<div class="controls-row">
<div class="controls track-controls queue-not-focused desktop-and-up">
<div class="ui tiny image" @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})">
<img alt="" ref="cover" v-if="currentTrack.cover && currentTrack.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.medium_square_crop)">
<img alt="" ref="cover" v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls && currentTrack.album.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.medium_square_crop)">
<img alt="" v-else src="../../assets/audio/default-cover.png">
<div
class="ui tiny image"
@click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
>
<img
v-if="currentTrack.cover && currentTrack.cover.urls.original"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.medium_square_crop)"
>
<img
v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls && currentTrack.album.cover.urls.original"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.medium_square_crop)"
>
<img
v-else
alt=""
src="../../assets/audio/default-cover.png"
>
</div>
<div @click.stop.prevent="" class="middle aligned content ellipsis">
<div
class="middle aligned content ellipsis"
@click.stop.prevent=""
>
<strong>
<router-link @click.stop.prevent="" class="small header discrete link track" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}">
<router-link
class="small header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
@click.stop.prevent=""
>
{{ currentTrack.title }}
</router-link>
</strong>
<div class="meta">
<router-link @click.stop.prevent="" class="discrete link" :to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}">{{ currentTrack.artist.name }}</router-link>
<template v-if="currentTrack.album"> /
<router-link @click.stop.prevent="" class="discrete link" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">{{ currentTrack.album.title }}</router-link>
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"
@click.stop.prevent=""
>
{{ currentTrack.artist.name }}
</router-link>
<template v-if="currentTrack.album">
/
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"
@click.stop.prevent=""
>
{{ currentTrack.album.title }}
</router-link>
</template>
</div>
</div>
</div>
<div class="controls track-controls queue-not-focused tablet-and-below">
<div class="ui tiny image">
<img alt="" ref="cover" v-if="currentTrack.cover && currentTrack.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.medium_square_crop)">
<img alt="" ref="cover" v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.medium_square_crop)">
<img alt="" v-else src="../../assets/audio/default-cover.png">
<img
v-if="currentTrack.cover && currentTrack.cover.urls.original"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.medium_square_crop)"
>
<img
v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.original"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.medium_square_crop)"
>
<img
v-else
alt=""
src="../../assets/audio/default-cover.png"
>
</div>
<div class="middle aligned content ellipsis">
<strong>
{{ currentTrack.title }}
</strong>
<div class="meta">
{{ currentTrack.artist.name }}<template v-if="currentTrack.album"> / {{ currentTrack.album.title }}</template>
{{ currentTrack.artist.name }}<template v-if="currentTrack.album">
/ {{ currentTrack.album.title }}
</template>
</div>
</div>
</div>
<div class="controls desktop-and-up fluid align-right" v-if="$store.state.auth.authenticated">
<div
v-if="$store.state.auth.authenticated"
class="controls desktop-and-up fluid align-right"
>
<track-favorite-icon
class="control white"
:track="currentTrack"></track-favorite-icon>
:track="currentTrack"
/>
<track-playlist-icon
class="control white"
:track="currentTrack"></track-playlist-icon>
:track="currentTrack"
/>
<button
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
:aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter">
<i :class="['eye slash outline', 'basic', 'icon']"></i>
:title="labels.addArtistContentFilter"
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button>
</div>
<div class="player-controls controls queue-not-focused">
@ -66,41 +146,48 @@
:title="labels.previous"
:aria-label="labels.previous"
class="circular button control tablet-and-up"
:disabled="!hasPrevious"
@click.prevent.stop="$store.dispatch('queue/previous')"
:disabled="!hasPrevious">
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" ></i>
>
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
</button>
<button
v-if="!playing"
:title="labels.play"
:aria-label="labels.play"
class="circular button control"
@click.prevent.stop="resumePlayback"
class="circular button control">
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']"></i>
>
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
v-else
:title="labels.pause"
:aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="pausePlayback"
class="circular button control">
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']"></i>
>
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
:title="labels.next"
:aria-label="labels.next"
class="circular button control"
:disabled="!hasNext"
@click.prevent.stop="$store.dispatch('queue/next')"
:disabled="!hasNext">
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" ></i>
>
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
</button>
</div>
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
<div class="timer">
<template v-if="!isLoadingAudio">
<span class="start" @click.stop.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</span>
| <span class="total">{{durationFormatted}}</span>
<span
class="start"
@click.stop.prevent="setCurrentTime(0)"
>{{ currentTimeFormatted }}</span>
| <span class="total">{{ durationFormatted }}</span>
</template>
<template v-else>
00:00 | 00:00
@ -111,35 +198,40 @@
<div class="group">
<volume-control class="expandable" />
<button
class="circular control button"
v-if="looping === 0"
class="circular control button"
:title="labels.loopingDisabled"
:aria-label="labels.loopingDisabled"
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 1)"
:disabled="!currentTrack">
<i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']"></i>
>
<i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']" />
</button>
<button
v-if="looping === 1"
class="looping circular control button"
@click.prevent.stop="$store.commit('player/looping', 2)"
:title="labels.loopingSingle"
:aria-label="labels.loopingSingle"
v-if="looping === 1"
:disabled="!currentTrack">
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 2)"
>
<i
class="repeat icon">
class="repeat icon"
>
<span class="ui circular tiny vibrant label">1</span>
</i>
</button>
<button
v-if="looping === 2"
class="looping circular control button"
:title="labels.loopingWhole"
:aria-label="labels.loopingWhole"
v-if="looping === 2"
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 0)">
@click.prevent.stop="$store.commit('player/looping', 0)"
>
<i
class="repeat icon">
class="repeat icon"
>
<span class="ui circular tiny vibrant label">&infin;</span>
</i>
</button>
@ -148,55 +240,80 @@
:disabled="queue.tracks.length === 0"
:title="labels.shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()">
<div v-if="isShuffling" class="ui inline shuffling inverted tiny active loader"></div>
<i v-else :class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
@click.prevent.stop="shuffle()"
>
<div
v-if="isShuffling"
class="ui inline shuffling inverted tiny active loader"
/>
<i
v-else
:class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']"
/>
</button>
</div>
<div class="group">
<div class="fake-dropdown">
<button class="position circular control button desktop-and-up" @click.stop="toggleMobilePlayer" aria-expanded="true">
<i class="stream icon"></i>
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
<button
class="position circular control button desktop-and-up"
aria-expanded="true"
@click.stop="toggleMobilePlayer"
>
<i class="stream icon" />
<translate
translate-context="Sidebar/Queue/Text"
:translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"
>
%{ index } of %{ length }
</translate>
</button>
<button class="position circular control button tablet-and-below" @click.stop="switchTab">
<i class="stream icon"></i>
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}">
<button
class="position circular control button tablet-and-below"
@click.stop="switchTab"
>
<i class="stream icon" />
<translate
translate-context="Sidebar/Queue/Text"
:translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"
>
%{ index } of %{ length }
</translate>
</button>
<button
class="circular control button close-control desktop-and-up"
v-if="$store.state.ui.queueFocused"
@click.stop="toggleMobilePlayer">
<i class="large down angle icon"></i>
class="circular control button close-control desktop-and-up"
@click.stop="toggleMobilePlayer"
>
<i class="large down angle icon" />
</button>
<button
class="circular control button desktop-and-up"
v-else
@click.stop="toggleMobilePlayer">
<i class="large up angle icon"></i>
class="circular control button desktop-and-up"
@click.stop="toggleMobilePlayer"
>
<i class="large up angle icon" />
</button>
<button
class="circular control button close-control tablet-and-below"
v-if="$store.state.ui.queueFocused === 'player'"
@click.stop="switchTab">
<i class="large up angle icon"></i>
class="circular control button close-control tablet-and-below"
@click.stop="switchTab"
>
<i class="large up angle icon" />
</button>
<button
class="circular control button tablet-and-below"
v-if="$store.state.ui.queueFocused === 'queue'"
@click.stop="switchTab">
<i class="large down angle icon"></i>
class="circular control button tablet-and-below"
@click.stop="switchTab"
>
<i class="large down angle icon" />
</button>
</div>
<button
class="circular control button close-control tablet-and-below"
@click.stop="$store.commit('ui/queueFocused', null)">
<i class="x icon"></i>
@click.stop="$store.commit('ui/queueFocused', null)"
>
<i class="x icon" />
</button>
</div>
</div>
@ -219,7 +336,7 @@
@keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
@keydown.q.exact="clean"
@keydown.e.exact="toggleMobilePlayer"
/>
/>
</section>
</template>
@ -259,6 +376,124 @@ export default {
nextTrackPreloaded: false
}
},
computed: {
...mapState({
currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume,
looping: state => state.player.looping,
duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
currentTime: state => state.player.currentTime,
queue: state => state.queue
}),
...mapGetters({
currentTrack: 'queue/currentTrack',
hasNext: 'queue/hasNext',
hasPrevious: 'queue/hasPrevious',
emptyQueue: 'queue/isEmpty',
durationFormatted: 'player/durationFormatted',
currentTimeFormatted: 'player/currentTimeFormatted',
progress: 'player/progress'
}),
updateProgressThrottled () {
return _.throttle(this.updateProgress, 50)
},
labels () {
const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player')
const previous = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track')
const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play')
const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause')
const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
const unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute')
const mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute')
const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue')
const loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping disabled. Click to switch to single-track looping.'
)
const loopingSingle = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping on a single track. Click to switch to whole queue looping.'
)
const loopingWhole = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping on whole queue. Click to disable looping.'
)
const shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Shuffle your queue')
const clear = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Clear your queue')
const addArtistContentFilter = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…')
return {
audioPlayer,
previous,
play,
pause,
next,
unmute,
mute,
loopingDisabled,
loopingSingle,
loopingWhole,
shuffle,
clear,
expandQueue,
addArtistContentFilter
}
}
},
watch: {
currentTrack: {
async handler (newValue, oldValue) {
if (newValue === oldValue) {
return
}
this.nextTrackPreloaded = false
clearTimeout(this.playTimeout)
if (this.currentSound) {
this.currentSound.pause()
}
this.$store.commit('player/isLoadingAudio', true)
this.playTimeout = setTimeout(async () => {
await this.loadSound(newValue, oldValue)
}, 100)
this.updateMetadata()
},
immediate: false
},
volume: {
immediate: true,
handler (newValue) {
this.sliderVolume = newValue
Howler.volume(toLinearVolumeScale(newValue))
}
},
sliderVolume (newValue) {
this.$store.commit('player/volume', newValue)
},
playing: async function (newValue) {
if (this.currentSound) {
if (newValue === true) {
this.soundId = this.currentSound.play(this.soundId)
} else {
this.currentSound.pause(this.soundId)
}
} else {
await this.loadSound(this.currentTrack, null)
}
this.observeProgress(newValue)
},
currentTime (newValue) {
if (!this.isUpdatingTime) {
this.setCurrentTime(newValue)
}
this.isUpdatingTime = false
},
emptyQueue (newValue) {
if (newValue) {
Howler.unload()
}
}
},
mounted () {
this.$store.dispatch('player/updateProgress', 0)
this.$store.commit('player/playing', false)
@ -661,124 +896,6 @@ export default {
navigator.mediaSession.metadata = new window.MediaMetadata(metadata)
}
}
},
computed: {
...mapState({
currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume,
looping: state => state.player.looping,
duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
currentTime: state => state.player.currentTime,
queue: state => state.queue
}),
...mapGetters({
currentTrack: 'queue/currentTrack',
hasNext: 'queue/hasNext',
hasPrevious: 'queue/hasPrevious',
emptyQueue: 'queue/isEmpty',
durationFormatted: 'player/durationFormatted',
currentTimeFormatted: 'player/currentTimeFormatted',
progress: 'player/progress'
}),
updateProgressThrottled () {
return _.throttle(this.updateProgress, 50)
},
labels () {
const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player')
const previous = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track')
const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play')
const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause')
const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
const unmute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute')
const mute = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute')
const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue')
const loopingDisabled = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping disabled. Click to switch to single-track looping.'
)
const loopingSingle = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping on a single track. Click to switch to whole queue looping.'
)
const loopingWhole = this.$pgettext('Sidebar/Player/Icon.Tooltip',
'Looping on whole queue. Click to disable looping.'
)
const shuffle = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Shuffle your queue')
const clear = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Clear your queue')
const addArtistContentFilter = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…')
return {
audioPlayer,
previous,
play,
pause,
next,
unmute,
mute,
loopingDisabled,
loopingSingle,
loopingWhole,
shuffle,
clear,
expandQueue,
addArtistContentFilter
}
}
},
watch: {
currentTrack: {
async handler (newValue, oldValue) {
if (newValue === oldValue) {
return
}
this.nextTrackPreloaded = false
clearTimeout(this.playTimeout)
if (this.currentSound) {
this.currentSound.pause()
}
this.$store.commit('player/isLoadingAudio', true)
this.playTimeout = setTimeout(async () => {
await this.loadSound(newValue, oldValue)
}, 100)
this.updateMetadata()
},
immediate: false
},
volume: {
immediate: true,
handler (newValue) {
this.sliderVolume = newValue
Howler.volume(toLinearVolumeScale(newValue))
}
},
sliderVolume (newValue) {
this.$store.commit('player/volume', newValue)
},
playing: async function (newValue) {
if (this.currentSound) {
if (newValue === true) {
this.soundId = this.currentSound.play(this.soundId)
} else {
this.currentSound.pause(this.soundId)
}
} else {
await this.loadSound(this.currentTrack, null)
}
this.observeProgress(newValue)
},
currentTime (newValue) {
if (!this.isUpdatingTime) {
this.setCurrentTime(newValue)
}
this.isUpdatingTime = false
},
emptyQueue (newValue) {
if (newValue) {
Howler.unload()
}
}
}
}
</script>

View File

@ -1,29 +1,69 @@
<template>
<div>
<h2><translate translate-context="Content/Search/Title">Search for some music</translate></h2>
<h2>
<translate translate-context="Content/Search/Title">
Search for some music
</translate>
</h2>
<div :class="['ui', {'loading': isLoading }, 'search']">
<div class="ui icon big input">
<i class="search icon"></i>
<input ref="search" class="prompt" :placeholder="labels.searchPlaceholder" v-model.trim="query" type="text" />
<i class="search icon" />
<input
ref="search"
v-model.trim="query"
class="prompt"
:placeholder="labels.searchPlaceholder"
type="text"
>
</div>
</div>
<template v-if="query.length > 0">
<h3 class="ui title"><translate translate-context="*/*/*/Noun">Artists</translate></h3>
<h3 class="ui title">
<translate translate-context="*/*/*/Noun">
Artists
</translate>
</h3>
<div v-if="results.artists.length > 0">
<div class="ui cards">
<artist-card :key="artist.id" v-for="artist in results.artists" :artist="artist" ></artist-card>
<artist-card
v-for="artist in results.artists"
:key="artist.id"
:artist="artist"
/>
</div>
</div>
<p v-else><translate translate-context="Content/Search/Paragraph">No artist matched your query</translate></p>
<p v-else>
<translate translate-context="Content/Search/Paragraph">
No artist matched your query
</translate>
</p>
</template>
<template v-if="query.length > 0">
<h3 class="ui title"><translate translate-context="*/*/*">Albums</translate></h3>
<div v-if="results.albums.length > 0" class="ui stackable three column grid">
<div class="column" :key="album.id" v-for="album in results.albums">
<album-card class="fluid" :album="album" ></album-card>
<h3 class="ui title">
<translate translate-context="*/*/*">
Albums
</translate>
</h3>
<div
v-if="results.albums.length > 0"
class="ui stackable three column grid"
>
<div
v-for="album in results.albums"
:key="album.id"
class="column"
>
<album-card
class="fluid"
:album="album"
/>
</div>
</div>
<p v-else><translate translate-context="Content/Search/Paragraph">No album matched your query</translate></p>
<p v-else>
<translate translate-context="Content/Search/Paragraph">
No album matched your query
</translate>
</p>
</template>
</div>
</template>
@ -41,7 +81,7 @@ export default {
ArtistCard
},
props: {
autofocus: {type: Boolean, default: false}
autofocus: { type: Boolean, default: false }
},
data () {
return {
@ -53,12 +93,6 @@ export default {
isLoading: false
}
},
mounted () {
if (this.autofocus) {
this.$refs.search.focus()
}
this.search()
},
computed: {
labels () {
return {
@ -66,15 +100,26 @@ export default {
}
}
},
watch: {
query () {
this.search()
}
},
mounted () {
if (this.autofocus) {
this.$refs.search.focus()
}
this.search()
},
methods: {
search: _.debounce(function () {
if (this.query.length < 1) {
return
}
var self = this
const self = this
self.isLoading = true
logger.default.debug('Searching track matching "' + this.query + '"')
let params = {
const params = {
query: this.query
}
axios.get('search', {
@ -90,11 +135,6 @@ export default {
artists: results.artists
}
}
},
watch: {
query () {
this.search()
}
}
}
</script>

View File

@ -1,11 +1,19 @@
<template>
<div class="ui fluid category search">
<slot></slot><div class="ui icon input">
<input :aria-label="labels.searchContent" ref="search" type="search" class="prompt" name="search" :placeholder="labels.placeholder" @keydown.esc="$event.target.blur()">
<i class="search icon"></i>
<slot /><div class="ui icon input">
<input
ref="search"
:aria-label="labels.searchContent"
type="search"
class="prompt"
name="search"
:placeholder="labels.placeholder"
@keydown.esc="$event.target.blur()"
>
<i class="search icon" />
</div>
<div class="results"></div>
<slot name="after"></slot>
<div class="results" />
<slot name="after" />
<GlobalEvents
@keydown.shift.f.prevent.exact="focusSearch"
/>
@ -16,11 +24,11 @@
import jQuery from 'jquery'
import router from '@/router'
import lodash from '@/lodash'
import GlobalEvents from "@/components/utils/global-events"
import GlobalEvents from '@/components/utils/global-events'
export default {
components: {
GlobalEvents,
GlobalEvents
},
computed: {
labels () {
@ -31,22 +39,21 @@ export default {
}
},
mounted () {
let artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
let albumLabel = this.$pgettext('*/*/*', 'Album')
let trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
let tagLabel = this.$pgettext('*/*/*/Noun', 'Tag')
let self = this
var searchQuery;
const artistLabel = this.$pgettext('*/*/*/Noun', 'Artist')
const albumLabel = this.$pgettext('*/*/*', 'Album')
const trackLabel = this.$pgettext('*/*/*/Noun', 'Track')
const tagLabel = this.$pgettext('*/*/*/Noun', 'Tag')
const self = this
let searchQuery
jQuery(this.$el).keypress(function(e) {
if(e.which == 13) {
jQuery(this.$el).keypress(function (e) {
if (e.which === 13) {
// Cancel any API search request to backend
jQuery(this.$el).search('cancel query');
jQuery(this.$el).search('cancel query')
// Go direct to the artist page
router.push(`/search?q=${searchQuery}&type=artists`);
}
});
router.push(`/search?q=${searchQuery}&type=artists`)
}
})
jQuery(this.$el).search({
type: 'category',
@ -57,9 +64,9 @@ export default {
noResults: this.$pgettext('Sidebar/Search/Error.Label', 'Sorry, there are no results for this search')
},
onSelect (result, response) {
jQuery(self.$el).search("set value", searchQuery)
jQuery(self.$el).search('set value', searchQuery)
router.push(result.routerUrl)
jQuery(self.$el).search("hide results")
jQuery(self.$el).search('hide results')
return false
},
onSearchQuery (query) {
@ -78,17 +85,17 @@ export default {
return xhrObject
},
onResponse: function (initialResponse) {
let objId = self.extractObjId(searchQuery)
let results = {}
const objId = self.extractObjId(searchQuery)
const results = {}
let isEmptyResults = true
let categories = [
const categories = [
{
code: 'federation',
name: self.$pgettext('*/*/*', 'Federation'),
name: self.$pgettext('*/*/*', 'Federation')
},
{
code: 'podcasts',
name: self.$pgettext('*/*/*', 'Podcasts'),
name: self.$pgettext('*/*/*', 'Podcasts')
},
{
code: 'artists',
@ -148,12 +155,12 @@ export default {
},
getId (t) {
return t.name
},
}
},
{
code: 'more',
name: '',
},
name: ''
}
]
categories.forEach(category => {
results[category.code] = {
@ -161,29 +168,27 @@ export default {
results: []
}
if (category.code === 'federation') {
if (objId) {
isEmptyResults = false
let searchMessage = self.$pgettext('Search/*/*', 'Search on the fediverse')
results['federation'] = {
const searchMessage = self.$pgettext('Search/*/*', 'Search on the fediverse')
results.federation = {
name: self.$pgettext('*/*/*', 'Federation'),
results: [{
title: searchMessage,
routerUrl: {
name: 'search',
query: {
id: objId,
id: objId
}
}
}]
}
}
}
else if (category.code === 'podcasts') {
} else if (category.code === 'podcasts') {
if (objId) {
isEmptyResults = false
let searchMessage = self.$pgettext('Search/*/*', 'Subscribe to podcast via RSS')
results['podcasts'] = {
const searchMessage = self.$pgettext('Search/*/*', 'Subscribe to podcast via RSS')
results.podcasts = {
name: self.$pgettext('*/*/*', 'Podcasts'),
results: [{
title: searchMessage,
@ -191,33 +196,31 @@ export default {
name: 'search',
query: {
id: objId,
type: "rss"
type: 'rss'
}
}
}]
}
}
}
else if (category.code === 'more') {
let searchMessage = self.$pgettext('Search/*/*', 'More results 🡒')
results['more'] = {
} else if (category.code === 'more') {
const searchMessage = self.$pgettext('Search/*/*', 'More results 🡒')
results.more = {
name: '',
results: [{
title: searchMessage,
routerUrl: {
name: 'search',
query: {
type: "artists",
type: 'artists',
q: searchQuery
}
}
}]
}
}
else {
} else {
initialResponse[category.code].forEach(result => {
isEmptyResults = false
let id = category.getId(result)
const id = category.getId(result)
results[category.code].results.push({
title: category.getTitle(result),
id,

View File

@ -1,74 +1,87 @@
<template>
<button class="circular control button" :class="['component-volume-control', {'expanded': expanded}]" @click.prevent.stop="" @mouseover="handleOver" @mouseleave="handleLeave">
<button
class="circular control button"
:class="['component-volume-control', {'expanded': expanded}]"
@click.prevent.stop=""
@mouseover="handleOver"
@mouseleave="handleLeave"
>
<span
role="button"
v-if="sliderVolume === 0"
role="button"
:title="labels.unmute"
:aria-label="labels.unmute"
@click.prevent.stop="unmute">
<i class="volume off icon"></i>
@click.prevent.stop="unmute"
>
<i class="volume off icon" />
</span>
<span
role="button"
v-else-if="sliderVolume < 0.5"
role="button"
:title="labels.mute"
:aria-label="labels.mute"
@click.prevent.stop="mute">
<i class="volume down icon"></i>
@click.prevent.stop="mute"
>
<i class="volume down icon" />
</span>
<span
role="button"
v-else
role="button"
:title="labels.mute"
:aria-label="labels.mute"
@click.prevent.stop="mute">
<i class="volume up icon"></i>
@click.prevent.stop="mute"
>
<i class="volume up icon" />
</span>
<div class="popup">
<label for="volume-slider" class="visually-hidden">{{ labels.slider }}</label>
<label
for="volume-slider"
class="visually-hidden"
>{{ labels.slider }}</label>
<input
id="volume-slider"
v-model="sliderVolume"
type="range"
step="any"
min="0"
v-bind:max="volumeSteps"
v-model="sliderVolume" />
:max="volumeSteps"
>
</div>
</button>
</template>
<script>
import { mapState, mapGetters, mapActions } from "vuex"
import mapActions from 'vuex'
export default {
data () {
return {
expanded: false,
timeout: null,
volumeSteps: 100,
volumeSteps: 100
}
},
computed: {
sliderVolume: {
get () {
return this.$store.state.player.volume * this.volumeSteps;
return this.$store.state.player.volume * this.volumeSteps
},
set (v) {
this.$store.commit("player/volume", v / this.volumeSteps)
this.$store.commit('player/volume', v / this.volumeSteps)
}
},
labels () {
return {
unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute"),
mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute"),
slider: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Adjust volume")
unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'),
mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute'),
slider: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Adjust volume')
}
}
},
methods: {
...mapActions({
mute: "player/mute",
unmute: "player/unmute",
toggleMute: "player/toggleMute",
mute: 'player/mute',
unmute: 'player/unmute',
toggleMute: 'player/toggleMute'
}),
handleOver () {
if (this.timeout) {
@ -80,7 +93,7 @@ export default {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => {this.expanded = false}, 500)
this.timeout = setTimeout(() => { this.expanded = false }, 500)
}
}
}

View File

@ -1,19 +1,32 @@
<template>
<div class="card app-card component-album-card">
<div
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', 'image', {'default-cover': !album.cover || !album.cover.urls.original}]"
@click="$router.push({name: 'library.albums.detail', params: {id: album.id}})"
:class="['ui', 'head-image', 'image', {'default-cover': !album.cover || !album.cover.urls.original}]" v-lazy:background-image="imageUrl">
<play-button :icon-only="true" :is-playable="album.is_playable" :button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']" :album="album"></play-button>
>
<play-button
:icon-only="true"
:is-playable="album.is_playable"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:album="album"
/>
</div>
<div class="content">
<strong>
<router-link class="discrete link" :to="{name: 'library.albums.detail', params: {id: album.id}}">
<router-link
class="discrete link"
:to="{name: 'library.albums.detail', params: {id: album.id}}"
>
{{ album.title }}
</router-link>
</strong>
<div class="description">
<span>
<router-link class="discrete link" :to="{name: 'library.artists.detail', params: {id: album.artist.id}}">
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: album.artist.id}}"
>
{{ album.artist.name }}
</router-link>
</span>
@ -21,8 +34,21 @@
</div>
<div class="extra content">
<span v-if="album.release_date">{{ album.release_date | moment('Y') }} · </span>
<translate translate-context="*/*/*" :translate-params="{count: album.tracks_count}" :translate-n="album.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate>
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="album.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']" :album="album"></play-button>
<translate
translate-context="*/*/*"
:translate-params="{count: album.tracks_count}"
:translate-n="album.tracks_count"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate>
<play-button
class="right floated basic icon"
:dropdown-only="true"
:is-playable="album.is_playable"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:album="album"
/>
</div>
</div>
</template>
@ -31,17 +57,18 @@
import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
album: {type: Object},
},
components: {
PlayButton
},
props: {
album: { type: Object, required: true }
},
computed: {
imageUrl () {
if (this.album.cover && this.album.cover.urls.original) {
return this.$store.getters['instance/absoluteUrl'](this.album.cover.urls.medium_square_crop)
}
return null
}
}
}

View File

@ -1,25 +1,54 @@
<template>
<div class="wrapper">
<h3 v-if="!!this.$slots.title" class="ui header">
<slot name="title"></slot>
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
<h3
v-if="!!$slots.title"
class="ui header"
>
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<slot></slot>
<inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div>
<slot />
<inline-search-bar
v-if="search"
v-model="query"
@search="albums = []; fetchData()"
/>
<div class="ui hidden divider" />
<div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<album-card v-for="album in albums" :album="album" :key="album.id" />
<album-card
v-for="album in albums"
:key="album.id"
:album="album"
/>
</div>
<slot v-if="!isLoading && albums.length === 0" name="empty-state">
<empty-state @refresh="fetchData" :refresh="true"></empty-state>
<slot
v-if="!isLoading && albums.length === 0"
name="empty-state"
>
<empty-state
:refresh="true"
@refresh="fetchData"
/>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
</div>
@ -30,16 +59,16 @@ import axios from 'axios'
import AlbumCard from '@/components/audio/album/Card'
export default {
props: {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
showCount: {type: Boolean, default: false},
search: {type: Boolean, default: false},
limit: {type: Number, default: 12},
},
components: {
AlbumCard
},
props: {
filters: { type: Object, required: true },
controls: { type: Boolean, default: true },
showCount: { type: Boolean, default: false },
search: { type: Boolean, default: false },
limit: { type: Number, default: 12 }
},
data () {
return {
albums: [],
@ -48,7 +77,15 @@ export default {
errors: null,
previousPage: null,
nextPage: null,
query: '',
query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
}
},
created () {
@ -58,11 +95,11 @@ export default {
fetchData (url) {
url = url || 'albums/'
this.isLoading = true
let self = this
let params = {q: this.query, ...this.filters}
const self = this
const params = { q: this.query, ...this.filters }
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
@ -79,14 +116,6 @@ export default {
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
},
},
watch: {
offset () {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
}
}
}

View File

@ -1,49 +1,88 @@
<template>
<div class="app-card card">
<div
v-lazy:background-image="imageUrl"
:class="['ui', 'head-image', 'circular', 'image', {'default-cover': !cover || !cover.urls.original}]"
@click="$router.push({name: 'library.artists.detail', params: {id: artist.id}})"
:class="['ui', 'head-image', 'circular', 'image', {'default-cover': !cover || !cover.urls.original}]" v-lazy:background-image="imageUrl">
<play-button :icon-only="true" :is-playable="artist.is_playable" :button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']" :artist="artist"></play-button>
>
<play-button
:icon-only="true"
:is-playable="artist.is_playable"
:button-classes="['ui', 'circular', 'large', 'vibrant', 'icon', 'button']"
:artist="artist"
/>
</div>
<div class="content">
<strong>
<router-link class="discrete link" :to="{name: 'library.artists.detail', params: {id: artist.id}}">
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: artist.id}}"
>
{{ artist.name|truncate(30) }}
</router-link>
</strong>
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="artist.tags"></tags-list>
<tags-list
label-classes="tiny"
:truncate-size="20"
:limit="2"
:show-more="false"
:tags="artist.tags"
/>
</div>
<div class="extra content">
<translate v-if="artist.content_category === 'music'" translate-context="*/*/*" :translate-params="{count: artist.tracks_count}" :translate-n="artist.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate>
<translate v-else translate-context="*/*/*" :translate-params="{count: artist.tracks_count}" :translate-n="artist.tracks_count" translate-plural="%{ count } episodes">%{ count } episode</translate>
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="artist.is_playable" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']" :artist="artist"></play-button>
<translate
v-if="artist.content_category === 'music'"
translate-context="*/*/*"
:translate-params="{count: artist.tracks_count}"
:translate-n="artist.tracks_count"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate>
<translate
v-else
translate-context="*/*/*"
:translate-params="{count: artist.tracks_count}"
:translate-n="artist.tracks_count"
translate-plural="%{ count } episodes"
>
%{ count } episode
</translate>
<play-button
class="right floated basic icon"
:dropdown-only="true"
:is-playable="artist.is_playable"
:dropdown-icon-classes="['ellipsis', 'horizontal', 'large really discrete']"
:artist="artist"
/>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
import TagsList from '@/components/tags/List'
export default {
props: ['artist'],
components: {
PlayButton,
TagsList
},
props: { artist: { type: Object, required: true } },
data () {
return {
initialAlbums: 30,
showAllAlbums: true,
showAllAlbums: true
}
},
computed: {
imageUrl () {
let cover = this.cover
const cover = this.cover
if (cover && cover.urls.original) {
return this.$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)
}
return null
},
cover () {
if (this.artist.cover && this.artist.cover.urls.original) {
@ -54,7 +93,7 @@ export default {
}).filter((c) => {
return c && c.urls.original
})[0]
},
}
}
}
</script>

View File

@ -1,24 +1,50 @@
<template>
<div class="wrapper">
<h3 v-if="header" class="ui header">
<slot name="title"></slot>
<h3
v-if="header"
class="ui header"
>
<slot name="title" />
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar>
<div class="ui hidden divider"></div>
<inline-search-bar
v-if="search"
v-model="query"
@search="objects = []; fetchData()"
/>
<div class="ui hidden divider" />
<div class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<artist-card :artist="artist" v-for="artist in objects" :key="artist.id"></artist-card>
<artist-card
v-for="artist in objects"
:key="artist.id"
:artist="artist"
/>
</div>
<slot v-if="!isLoading && objects.length === 0" name="empty-state">
<empty-state @refresh="fetchData" :refresh="true"></empty-state>
<slot
v-if="!isLoading && objects.length === 0"
name="empty-state"
>
<empty-state
:refresh="true"
@refresh="fetchData"
/>
</slot>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
</div>
@ -26,17 +52,17 @@
<script>
import axios from 'axios'
import ArtistCard from "@/components/audio/artist/Card"
import ArtistCard from '@/components/audio/artist/Card'
export default {
props: {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
header: {type: Boolean, default: true},
search: {type: Boolean, default: false},
},
components: {
ArtistCard,
ArtistCard
},
props: {
filters: { type: Object, required: true },
controls: { type: Boolean, default: true },
header: { type: Boolean, default: true },
search: { type: Boolean, default: false }
},
data () {
return {
@ -47,7 +73,15 @@ export default {
errors: null,
previousPage: null,
nextPage: null,
query: '',
query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
}
},
created () {
@ -57,11 +91,11 @@ export default {
fetchData (url) {
url = url || 'artists/'
this.isLoading = true
let self = this
let params = {q: this.query, ...this.filters}
const self = this
const params = { q: this.query, ...this.filters }
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
@ -78,14 +112,6 @@ export default {
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
},
},
watch: {
offset () {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
}
}
}

View File

@ -7,12 +7,10 @@
>
<div
v-if="showArt"
@click.prevent.exact="activateTrack(track, index)"
class="image left floated column"
@click.prevent.exact="activateTrack(track, index)"
>
<img
alt=""
class="ui artist-track mini image"
v-if="
track.album && track.album.cover && track.album.cover.urls.original
"
@ -21,10 +19,10 @@
track.album.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.cover
"
@ -33,10 +31,10 @@
track.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.artist.cover
"
@ -45,19 +43,21 @@
track.artist.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png"
/>
>
</div>
<div
tabindex=0
@click="activateTrack(track, index)"
tabindex="0"
role="button"
class="content ellipsis left floated column"
@click="activateTrack(track, index)"
>
<p
:class="[
@ -68,24 +68,33 @@
>
{{ track.title }}
</p>
<p v-if="track.artist.content_category === 'podcast'" class="track-meta mobile">
<human-date class="really discrete" :date="track.creation_date"></human-date>
<p
v-if="track.artist.content_category === 'podcast'"
class="track-meta mobile"
>
<human-date
class="really discrete"
:date="track.creation_date"
/>
<span>&#183;</span>
<human-duration
v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration"
></human-duration>
/>
</p>
<p v-else class="track-meta mobile">
<p
v-else
class="track-meta mobile"
>
{{ track.artist.name }} <span>&#183;</span>
<human-duration
v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration"
></human-duration>
/>
</p>
</div>
<div
v-if="$store.state.auth.authenticated && this.track.artist.content_category !== 'podcast'"
v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'"
:class="[
'meta',
'right',
@ -100,12 +109,11 @@
class="tiny"
:border="false"
:track="track"
></track-favorite-icon>
/>
</div>
<div
role="button"
:aria-label="actionsButtonLabel"
@click.prevent.exact="showTrackModal = !showTrackModal"
:class="[
'modal-button',
'right',
@ -114,36 +122,36 @@
'mobile',
{ 'with-art': showArt },
]"
@click.prevent.exact="showTrackModal = !showTrackModal"
>
<i class="ellipsis large vertical icon" />
</div>
<track-modal
@update:show="showTrackModal = $event;"
:show="showTrackModal"
:track="track"
:index="index"
:is-artist="isArtist"
:is-album="isAlbum"
></track-modal>
@update:show="showTrackModal = $event;"
/>
</div>
</template>
<script>
import PlayIndicator from "@/components/audio/track/PlayIndicator";
import { mapActions, mapGetters } from "vuex";
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon";
import TrackModal from "@/components/audio/track/Modal";
import PlayOptionsMixin from "@/components/mixins/PlayOptions"
import { mapActions, mapGetters } from 'vuex'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackModal from '@/components/audio/track/Modal'
import PlayOptionsMixin from '@/components/mixins/PlayOptions'
export default {
mixins: [PlayOptionsMixin],
data() {
return {
showTrackModal: false,
}
components: {
TrackFavoriteIcon,
TrackModal
},
mixins: [PlayOptionsMixin],
props: {
tracks: Array,
tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -155,41 +163,40 @@ export default {
showDuration: { type: Boolean, required: false, default: true },
index: { type: Number, required: true },
track: { type: Object, required: true },
isArtist: {type: Boolean, required: false, default: false},
isAlbum: {type: Boolean, required: false, default: false},
isArtist: { type: Boolean, required: false, default: false },
isAlbum: { type: Boolean, required: false, default: false }
},
components: {
PlayIndicator,
TrackFavoriteIcon,
TrackModal,
data () {
return {
showTrackModal: false
}
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
currentTrack: 'queue/currentTrack'
}),
isPlaying() {
return this.$store.state.player.playing;
isPlaying () {
return this.$store.state.player.playing
},
actionsButtonLabel () {
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
},
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
}
},
methods: {
prettyPosition(position, size) {
var s = String(position);
prettyPosition (position, size) {
let s = String(position)
while (s.length < (size || 2)) {
s = "0" + s;
s = '0' + s
}
return s;
return s
},
...mapActions({
resumePlayback: "player/resumePlayback",
pausePlayback: "player/pausePlayback",
}),
},
};
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback'
})
}
}
</script>

View File

@ -15,8 +15,6 @@
@click.prevent.exact="activateTrack(track, index)"
>
<img
alt=""
class="ui artist-track mini image"
v-if="
track.cover && track.cover.urls.original
"
@ -25,10 +23,10 @@
track.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
defaultCover
"
@ -37,21 +35,32 @@
defaultCover.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png"
/>
>
</div>
<div tabindex=0 class="content left floated column">
<div
tabindex="0"
class="content left floated column"
>
<a
class="podcast-episode-title ellipsis"
@click.prevent.exact="activateTrack(track, index)">{{ track.title }}</a>
<p class="podcast-episode-meta">{{ description.text }}</p>
@click.prevent.exact="activateTrack(track, index)"
>{{ track.title }}</a>
<p class="podcast-episode-meta">
{{ description.text }}
</p>
</div>
<div v-if="displayActions" class="meta right floated column">
<div
v-if="displayActions"
class="meta right floated column"
>
<play-button
id="playmenu"
class="play-button basic icon"
@ -63,22 +72,25 @@
'large really discrete',
]"
:track="track"
></play-button>
/>
</div>
</div>
</template>
<script>
import axios from 'axios'
import PlayIndicator from "@/components/audio/track/PlayIndicator";
import { mapActions, mapGetters } from "vuex";
import PlayButton from "@/components/audio/PlayButton";
import PlayOptions from "@/components/mixins/PlayOptions";
import { mapActions, mapGetters } from 'vuex'
import PlayButton from '@/components/audio/PlayButton'
import PlayOptions from '@/components/mixins/PlayOptions'
export default {
components: {
PlayButton
},
mixins: [PlayOptions],
props: {
tracks: Array,
tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -90,34 +102,29 @@ export default {
showDuration: { type: Boolean, required: false, default: true },
index: { type: Number, required: true },
track: { type: Object, required: true },
defaultCover: { type: Object, required: false },
defaultCover: { type: Object, required: false, default: () => { return {} } }
},
data() {
data () {
return {
hover: null,
errors: null,
description: null,
description: null
}
},
created () {
this.fetchData('tracks/' + this.track.id + '/' )
},
components: {
PlayIndicator,
PlayButton,
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
currentTrack: 'queue/currentTrack'
}),
isPlaying() {
return this.$store.state.player.playing;
},
isPlaying () {
return this.$store.state.player.playing
}
},
created () {
this.fetchData('tracks/' + this.track.id + '/')
},
methods: {
@ -126,29 +133,29 @@ export default {
return
}
this.isLoading = true
let self = this
const self = this
try {
let channelsPromise = await axios.get(url)
const channelsPromise = await axios.get(url)
self.description = channelsPromise.data.description
self.isLoading = false
} catch(e) {
} catch (e) {
self.isLoading = false
self.errors = error.backendErrors
self.errors = e.backendErrors
}
},
prettyPosition(position, size) {
var s = String(position);
prettyPosition (position, size) {
let s = String(position)
while (s.length < (size || 2)) {
s = "0" + s;
s = '0' + s
}
return s;
return s
},
...mapActions({
resumePlayback: "player/resumePlayback",
pausePlayback: "player/pausePlayback",
}),
},
};
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback'
})
}
}
</script>

View File

@ -1,10 +1,10 @@
<template>
<div>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<!-- Add a header if needed -->
<slot name="header"></slot>
<slot name="header" />
<div>
<div
@ -13,38 +13,44 @@
<!-- For each item, build a row -->
<podcast-row
v-for="(track, index) in tracks"
:track="track"
:key="track.id"
:track="track"
:index="index"
:tracks="tracks"
:display-actions="displayActions"
:show-duration="showDuration"
:is-podcast="isPodcast"
></podcast-row>
/>
</div>
<div v-if="paginateResults" class="ui center aligned basic segment desktop-and-up">
<div
v-if="paginateResults"
class="ui center aligned basic segment desktop-and-up"
>
<pagination
:total="total"
:current="page"
:paginate-by="paginateBy"
v-on="$listeners">
</pagination>
v-on="$listeners"
/>
</div>
</div>
<div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<!-- For each item, build a row -->
<track-mobile-row
v-for="(track, index) in tracks"
:track="track"
:key="track.id"
:track="track"
:index="index"
:tracks="tracks"
:show-position="showPosition"
@ -53,36 +59,37 @@
:is-artist="isArtist"
:is-album="isAlbum"
:is-podcast="isPodcast"
></track-mobile-row>
<div v-if="paginateResults" class="ui center aligned basic segment tablet-and-below">
/>
<div
v-if="paginateResults"
class="ui center aligned basic segment tablet-and-below"
>
<pagination
v-if="paginateResults"
:total="total"
:current="page"
:compact="true"
v-on="$listeners"></pagination>
v-on="$listeners"
/>
</div>
</div>
</div>
</template>
<script>
import _ from "@/lodash";
import TrackRow from "@/components/audio/track/Row";
import PodcastRow from "@/components/audio/podcast/Row";
import TrackMobileRow from "@/components/audio/track/MobileRow";
import Pagination from "@/components/Pagination";
import PodcastRow from '@/components/audio/podcast/Row'
import TrackMobileRow from '@/components/audio/track/MobileRow'
import Pagination from '@/components/Pagination'
export default {
components: {
TrackRow,
TrackMobileRow,
Pagination,
PodcastRow,
PodcastRow
},
props: {
tracks: Array,
tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -94,33 +101,33 @@ export default {
showDuration: { type: Boolean, required: false, default: true },
isArtist: { type: Boolean, required: false, default: false },
isAlbum: { type: Boolean, required: false, default: false },
paginateResults: { type: Boolean, required: false, default: true},
total: { type: Number, required: false},
page: {type: Number, required: false, default: 1},
paginateBy: {type: Number, required: false, default: 25},
isPodcast: {type: Boolean, required: true},
defaultCover: {type: Object, required: false},
paginateResults: { type: Boolean, required: false, default: true },
total: { type: Number, required: false, default: 0 },
page: { type: Number, required: false, default: 1 },
paginateBy: { type: Number, required: false, default: 25 },
isPodcast: { type: Boolean, required: true },
defaultCover: { type: Object, required: false, default: () => { return {} } }
},
data() {
data () {
return {
isLoading: false,
};
isLoading: false
}
},
computed: {
labels() {
labels () {
return {
title: this.$pgettext("*/*/*/Noun", "Title"),
album: this.$pgettext("*/*/*/Noun", "Album"),
artist: this.$pgettext("*/*/*/Noun", "Artist"),
};
},
},
methods: {
updatePage: function(page) {
this.$emit('page-changed', page)
title: this.$pgettext('*/*/*/Noun', 'Title'),
album: this.$pgettext('*/*/*/Noun', 'Album'),
artist: this.$pgettext('*/*/*/Noun', 'Artist')
}
}
},
};
methods: {
updatePage: function (page) {
this.$emit('page-changed', page)
}
}
}
</script>

View File

@ -7,12 +7,10 @@
>
<div
v-if="showArt"
@click.prevent.exact="activateTrack(track, index)"
class="image left floated column"
@click.prevent.exact="activateTrack(track, index)"
>
<img
alt=""
class="ui artist-track mini image"
v-if="
track.album && track.album.cover && track.album.cover.urls.original
"
@ -21,10 +19,10 @@
track.album.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.cover
"
@ -33,10 +31,10 @@
track.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.artist.cover
"
@ -45,19 +43,21 @@
track.artist.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png"
/>
>
</div>
<div
tabindex=0
@click="activateTrack(track, index)"
tabindex="0"
role="button"
class="content ellipsis left floated column"
@click="activateTrack(track, index)"
>
<p
:class="[
@ -73,7 +73,7 @@
<human-duration
v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration"
></human-duration>
/>
</p>
</div>
<div
@ -92,12 +92,11 @@
class="tiny"
:border="false"
:track="track"
></track-favorite-icon>
/>
</div>
<div
role="button"
:aria-label="actionsButtonLabel"
@click.prevent.exact="showTrackModal = !showTrackModal"
:class="[
'modal-button',
'right',
@ -106,36 +105,36 @@
'mobile',
{ 'with-art': showArt },
]"
@click.prevent.exact="showTrackModal = !showTrackModal"
>
<i class="ellipsis large vertical icon" />
</div>
<track-modal
@update:show="showTrackModal = $event;"
:show="showTrackModal"
:track="track"
:index="index"
:is-artist="isArtist"
:is-album="isAlbum"
></track-modal>
@update:show="showTrackModal = $event;"
/>
</div>
</template>
<script>
import PlayIndicator from "@/components/audio/track/PlayIndicator";
import { mapActions, mapGetters } from "vuex";
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon";
import TrackModal from "@/components/audio/track/Modal";
import PlayOptionsMixin from "@/components/mixins/PlayOptions"
import { mapActions, mapGetters } from 'vuex'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackModal from '@/components/audio/track/Modal'
import PlayOptionsMixin from '@/components/mixins/PlayOptions'
export default {
mixins: [PlayOptionsMixin],
data() {
return {
showTrackModal: false,
}
components: {
TrackFavoriteIcon,
TrackModal
},
mixins: [PlayOptionsMixin],
props: {
tracks: Array,
tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -147,41 +146,40 @@ export default {
showDuration: { type: Boolean, required: false, default: true },
index: { type: Number, required: true },
track: { type: Object, required: true },
isArtist: {type: Boolean, required: false, default: false},
isAlbum: {type: Boolean, required: false, default: false},
isArtist: { type: Boolean, required: false, default: false },
isAlbum: { type: Boolean, required: false, default: false }
},
components: {
PlayIndicator,
TrackFavoriteIcon,
TrackModal,
data () {
return {
showTrackModal: false
}
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
currentTrack: 'queue/currentTrack'
}),
isPlaying() {
return this.$store.state.player.playing;
isPlaying () {
return this.$store.state.player.playing
},
actionsButtonLabel () {
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
},
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
}
},
methods: {
prettyPosition(position, size) {
var s = String(position);
prettyPosition (position, size) {
let s = String(position)
while (s.length < (size || 2)) {
s = "0" + s;
s = '0' + s
}
return s;
return s
},
...mapActions({
resumePlayback: "player/resumePlayback",
pausePlayback: "player/pausePlayback",
}),
},
};
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback'
})
}
}
</script>

View File

@ -1,8 +1,8 @@
<template>
<div id="audio-bars">
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar"></div>
<div class="audio-bar" />
<div class="audio-bar" />
<div class="audio-bar" />
<div class="audio-bar" />
</div>
</template>
</template>

View File

@ -16,19 +16,18 @@
<play-indicator
v-if="
!$store.state.player.isLoadingAudio &&
currentTrack &&
isPlaying &&
track.id === currentTrack.id &&
!(track.id == hover)
currentTrack &&
isPlaying &&
track.id === currentTrack.id &&
!(track.id == hover)
"
>
</play-indicator>
/>
<button
v-else-if="
currentTrack &&
!isPlaying &&
track.id === currentTrack.id &&
!track.id == hover
!isPlaying &&
track.id === currentTrack.id &&
!track.id == hover
"
class="ui really tiny basic icon button play-button paused"
>
@ -37,9 +36,9 @@
<button
v-else-if="
currentTrack &&
isPlaying &&
track.id === currentTrack.id &&
track.id == hover
isPlaying &&
track.id === currentTrack.id &&
track.id == hover
"
class="ui really tiny basic icon button play-button"
>
@ -51,7 +50,10 @@
>
<i class="play icon" />
</button>
<span class="track-position" v-else-if="showPosition">
<span
v-else-if="showPosition"
class="track-position"
>
{{ prettyPosition(track.position) }}
</span>
</div>
@ -62,8 +64,6 @@
@click.prevent.exact="activateTrack(track, index)"
>
<img
alt=""
class="ui artist-track mini image"
v-if="
track.album && track.album.cover && track.album.cover.urls.original
"
@ -72,10 +72,10 @@
track.album.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.cover && track.cover.urls.original
"
@ -84,10 +84,10 @@
track.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else-if="
track.artist && track.artist.cover && track.album.cover.urls.original
"
@ -96,36 +96,49 @@
track.cover.urls.medium_square_crop
)
"
/>
<img
alt=""
class="ui artist-track mini image"
>
<img
v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png"
/>
>
</div>
<div tabindex=0 class="content ellipsis left floated column">
<div
tabindex="0"
class="content ellipsis left floated column"
>
<a
@click="activateTrack(track, index)"
>
{{ track.title }}
</a>
</div>
<div v-if="showAlbum" class="content ellipsis left floated column">
<div
v-if="showAlbum"
class="content ellipsis left floated column"
>
<router-link
:to="{ name: 'library.albums.detail', params: { id: track.album.id } }"
>{{ track.album.title }}</router-link
>
{{ track.album.title }}
</router-link>
</div>
<div v-if="showArtist" class="content ellipsis left floated column">
<div
v-if="showArtist"
class="content ellipsis left floated column"
>
<router-link
class="artist link"
:to="{
name: 'library.artists.detail',
params: { id: track.artist.id },
}"
>{{ track.artist.name }}</router-link
>
{{ track.artist.name }}
</router-link>
</div>
<div
v-if="$store.state.auth.authenticated"
@ -135,15 +148,21 @@
class="tiny"
:border="false"
:track="track"
></track-favorite-icon>
/>
</div>
<div v-if="showDuration" class="meta right floated column">
<div
v-if="showDuration"
class="meta right floated column"
>
<human-duration
v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration"
></human-duration>
/>
</div>
<div v-if="displayActions" class="meta right floated column">
<div
v-if="displayActions"
class="meta right floated column"
>
<play-button
id="playmenu"
class="play-button basic icon"
@ -155,22 +174,28 @@
'large really discrete',
]"
:track="track"
></play-button>
/>
</div>
</div>
</template>
<script>
import PlayIndicator from "@/components/audio/track/PlayIndicator";
import { mapActions, mapGetters } from "vuex";
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon";
import PlayButton from "@/components/audio/PlayButton";
import PlayOptions from "@/components/mixins/PlayOptions";
import PlayIndicator from '@/components/audio/track/PlayIndicator'
import { mapActions, mapGetters } from 'vuex'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import PlayButton from '@/components/audio/PlayButton'
import PlayOptions from '@/components/mixins/PlayOptions'
export default {
components: {
PlayIndicator,
TrackFavoriteIcon,
PlayButton
},
mixins: [PlayOptions],
props: {
tracks: Array,
tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -181,45 +206,39 @@ export default {
displayActions: { type: Boolean, required: false, default: true },
showDuration: { type: Boolean, required: false, default: true },
index: { type: Number, required: true },
track: { type: Object, required: true },
track: { type: Object, required: true }
},
data() {
data () {
return {
hover: null,
hover: null
}
},
components: {
PlayIndicator,
TrackFavoriteIcon,
PlayButton,
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
currentTrack: 'queue/currentTrack'
}),
isPlaying() {
return this.$store.state.player.playing;
},
isPlaying () {
return this.$store.state.player.playing
}
},
methods: {
prettyPosition(position, size) {
var s = String(position);
prettyPosition (position, size) {
let s = String(position)
while (s.length < (size || 2)) {
s = "0" + s;
s = '0' + s
}
return s;
return s
},
...mapActions({
resumePlayback: "player/resumePlayback",
pausePlayback: "player/pausePlayback",
}),
},
};
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback'
})
}
}
</script>

View File

@ -2,65 +2,95 @@
<div>
<!-- Show the search bar if search is true -->
<inline-search-bar
v-model="query"
v-if="search"
v-model="query"
@search="
additionalTracks = [];
fetchData();
"
></inline-search-bar>
<div class="ui hidden divider"></div>
/>
<div class="ui hidden divider" />
<!-- Add a header if needed -->
<slot name="header"></slot>
<slot name="header" />
<!-- Show a message if no tracks are available -->
<slot v-if="!isLoading && allTracks.length === 0" name="empty-state">
<slot
v-if="!isLoading && allTracks.length === 0"
name="empty-state"
>
<empty-state
@refresh="fetchData('tracks/')"
:refresh="true"
></empty-state>
@refresh="fetchData('tracks/')"
/>
</slot>
<div v-else>
<div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-up']"
>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<div class="track-table row">
<div v-if="showPosition" class="actions left floated column">
<i class="hashtag icon"></i>
<div
v-if="showPosition"
class="actions left floated column"
>
<i class="hashtag icon" />
</div>
<div v-else class="actions left floated column"></div>
<div v-if="showArt" class="image left floated column"></div>
<div
v-else
class="actions left floated column"
/>
<div
v-if="showArt"
class="image left floated column"
/>
<div class="content ellipsis left floated column">
<b>{{ labels.title }}</b>
</div>
<div v-if="showAlbum" class="content ellipsisleft floated column">
<div
v-if="showAlbum"
class="content ellipsisleft floated column"
>
<b>{{ labels.album }}</b>
</div>
<div v-if="showArtist" class="content ellipsis left floated column">
<div
v-if="showArtist"
class="content ellipsis left floated column"
>
<b>{{ labels.artist }}</b>
</div>
<div
v-if="$store.state.auth.authenticated"
class="meta right floated column"
></div>
<div v-if="showDuration" class="meta right floated column">
<i class="clock outline icon" style="padding: 0.5rem" />
/>
<div
v-if="showDuration"
class="meta right floated column"
>
<i
class="clock outline icon"
style="padding: 0.5rem"
/>
</div>
<div v-if="displayActions" class="meta right floated column"></div>
<div
v-if="displayActions"
class="meta right floated column"
/>
</div>
<!-- For each item, build a row -->
<track-row
v-for="(track, index) in allTracks"
:track="track"
:key="track.id"
:track="track"
:index="index"
:tracks="allTracks"
:show-album="showAlbum"
@ -70,31 +100,37 @@
:display-actions="displayActions"
:show-duration="showDuration"
:is-podcast="isPodcast"
></track-row>
/>
</div>
<div v-if="paginateResults" class="ui center aligned basic segment desktop-and-up">
<div
v-if="paginateResults"
class="ui center aligned basic segment desktop-and-up"
>
<pagination
:total="total"
:current="page"
:paginate-by="paginateBy"
v-on="$listeners">
</pagination>
v-on="$listeners"
/>
</div>
</div>
<div
:class="['track-table', 'ui', 'unstackable', 'grid', 'tablet-and-below']"
>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<!-- For each item, build a row -->
<track-mobile-row
v-for="(track, index) in allTracks"
:track="track"
:key="track.id"
:track="track"
:index="index"
:tracks="allTracks"
:show-position="showPosition"
@ -103,35 +139,39 @@
:is-artist="isArtist"
:is-album="isAlbum"
:is-podcast="isPodcast"
></track-mobile-row>
<div v-if="paginateResults" class="ui center aligned basic segment tablet-and-below">
/>
<div
v-if="paginateResults"
class="ui center aligned basic segment tablet-and-below"
>
<pagination
v-if="paginateResults"
:total="total"
:current="page"
:compact="true"
v-on="$listeners"></pagination>
v-on="$listeners"
/>
</div>
</div>
</div>
</template>
<script>
import _ from "@/lodash";
import axios from "axios";
import TrackRow from "@/components/audio/track/Row";
import TrackMobileRow from "@/components/audio/track/MobileRow";
import Pagination from "@/components/Pagination";
import _ from '@/lodash'
import axios from 'axios'
import TrackRow from '@/components/audio/track/Row'
import TrackMobileRow from '@/components/audio/track/MobileRow'
import Pagination from '@/components/Pagination'
export default {
components: {
TrackRow,
TrackMobileRow,
Pagination,
Pagination
},
props: {
tracks: Array,
tracks: { type: Array, default: () => { return [] } },
showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false },
@ -144,66 +184,66 @@ export default {
isArtist: { type: Boolean, required: false, default: false },
isAlbum: { type: Boolean, required: false, default: false },
isPodcast: { type: Boolean, required: false, default: false },
paginateResults: { type: Boolean, required: false, default: true},
total: { type: Number, required: false},
page: {type: Number, required: false, default: 1},
paginateBy: {type: Number, required: false, default: 25}
paginateResults: { type: Boolean, required: false, default: true },
total: { type: Number, required: false, default: 0 },
page: { type: Number, required: false, default: 1 },
paginateBy: { type: Number, required: false, default: 25 }
},
data() {
data () {
return {
fetchDataUrl: this.nextUrl,
isLoading: false,
additionalTracks: [],
query: "",
};
query: ''
}
},
computed: {
allTracks() {
return (this.tracks || []).concat(this.additionalTracks);
allTracks () {
return (this.tracks || []).concat(this.additionalTracks)
},
labels() {
labels () {
return {
title: this.$pgettext("*/*/*/Noun", "Title"),
album: this.$pgettext("*/*/*/Noun", "Album"),
artist: this.$pgettext("*/*/*/Noun", "Artist"),
};
},
title: this.$pgettext('*/*/*/Noun', 'Title'),
album: this.$pgettext('*/*/*/Noun', 'Album'),
artist: this.$pgettext('*/*/*/Noun', 'Artist')
}
}
},
created () {
if (!this.tracks) {
this.fetchData('tracks/')
}
},
methods: {
async fetchData(url) {
async fetchData (url) {
if (!url) {
return;
return
}
this.isLoading = true;
let self = this;
let params = _.clone(this.filters);
let tracksPromise = axios.get(url, { params: params })
params.page_size = this.limit;
params.page = this.page;
params.include_channels = true;
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const tracksPromise = axios.get(url, { params: params })
params.page_size = this.limit
params.page = this.page
params.include_channels = true
try {
await tracksPromise
self.nextPage = tracksPromise.data.next;
self.objects = tracksPromise.data.results;
self.count = tracksPromise.data.count;
self.$emit("fetched", tracksPromise.data);
self.isLoading = false;
} catch(e) {
self.isLoading = false;
self.errors = error.backendErrors;
self.nextPage = tracksPromise.data.next
self.objects = tracksPromise.data.results
self.count = tracksPromise.data.count
self.$emit('fetched', tracksPromise.data)
self.isLoading = false
} catch (e) {
self.isLoading = false
self.errors = e.backendErrors
}
},
updatePage: function(page) {
updatePage: function (page) {
this.$emit('page-changed', page)
}
},
created() {
if (!this.tracks) {
this.fetchData("tracks/");
}
},
};
}
}
</script>

View File

@ -1,17 +1,48 @@
<template>
<div class="component-track-widget">
<h3 v-if="!!this.$slots.title">
<slot name="title"></slot>
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
<h3 v-if="!!$slots.title">
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3>
<div v-if="count > 0" class="ui divided unstackable items">
<div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
<div
v-if="count > 0"
class="ui divided unstackable items"
>
<div
v-for="object in objects"
:key="object.id"
:class="['item', itemClasses]"
>
<div class="ui tiny image">
<img alt="" v-if="object.track.album && object.track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop)">
<img alt="" v-else-if="object.track.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop)"/>
<img alt="" v-else-if="object.track.artist.cover" v-lazy="$store.getters['instance/absoluteUrl'](object.track.artist.cover.urls.medium_square_crop)"/>
<img alt="" v-else src="../../../assets/audio/default-cover.png">
<play-button class="play-overlay" :icon-only="true" :button-classes="['ui', 'circular', 'tiny', 'vibrant', 'icon', 'button']" :track="object.track"></play-button>
<img
v-if="object.track.album && object.track.album.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop)"
alt=""
>
<img
v-else-if="object.track.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop)"
alt=""
>
<img
v-else-if="object.track.artist.cover"
v-lazy="$store.getters['instance/absoluteUrl'](object.track.artist.cover.urls.medium_square_crop)"
alt=""
>
<img
v-else
alt=""
src="../../../assets/audio/default-cover.png"
>
<play-button
class="play-overlay"
:icon-only="true"
:button-classes="['ui', 'circular', 'tiny', 'vibrant', 'icon', 'button']"
:track="object.track"
/>
</div>
<div class="middle aligned content">
<div class="ui unstackable grid">
@ -23,15 +54,32 @@
</div>
<div class="meta ellipsis">
<span>
<router-link class="discrete link" :to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}">
<router-link
class="discrete link"
:to="{name: 'library.artists.detail', params: {id: object.track.artist.id}}"
>
{{ object.track.artist.name }}
</router-link>
</span>
</div>
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.track.tags"></tags-list>
<tags-list
label-classes="tiny"
:truncate-size="20"
:limit="2"
:show-more="false"
:tags="object.track.tags"
/>
<div class="extra" v-if="isActivity">
<router-link class="left floated" :to="{name: 'profile.overview', params: {username: object.user.username}}">@{{ object.user.username }}</router-link>
<div
v-if="isActivity"
class="extra"
>
<router-link
class="left floated"
:to="{name: 'profile.overview', params: {username: object.user.username}}"
>
@{{ object.user.username }}
</router-link>
<span class="right floated"><human-date :date="object.creation_date" /></span>
</div>
</div>
@ -41,30 +89,46 @@
:account="object.actor"
:dropdown-only="true"
:dropdown-icon-classes="['ellipsis', 'vertical', 'large really discrete']"
:track="object.track"></play-button>
:track="object.track"
/>
</div>
</div>
</div>
</div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
</div>
<div v-else class="ui placeholder segment">
<div
v-else
class="ui placeholder segment"
>
<div class="ui icon header">
<i class="music icon"></i>
<i class="music icon" />
<translate translate-context="Content/Home/Placeholder">
Nothing found
</translate>
</div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
</div>
@ -74,21 +138,21 @@
import _ from '@/lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
import TagsList from '@/components/tags/List'
export default {
props: {
filters: {type: Object, required: true},
url: {type: String, required: true},
isActivity: {type: Boolean, default: true},
showCount: {type: Boolean, default: false},
limit: {type: Number, default: 5},
itemClasses: {type: String, default: ''},
},
components: {
PlayButton,
TagsList
},
props: {
filters: { type: Object, required: true },
url: { type: String, required: true },
isActivity: { type: Boolean, default: true },
showCount: { type: Boolean, default: false },
limit: { type: Number, default: 5 },
itemClasses: { type: String, default: '' }
},
data () {
return {
objects: [],
@ -99,6 +163,17 @@ export default {
nextPage: null
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData(this.url)
},
count (v) {
this.$emit('count', v)
}
},
created () {
this.fetchData(this.url)
},
@ -108,11 +183,11 @@ export default {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
const self = this
const params = _.clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
@ -123,7 +198,7 @@ export default {
newObjects = response.data.results
} else {
newObjects = response.data.results.map((r) => {
return {track: r}
return { track: r }
})
}
self.objects = [...self.objects, ...newObjects]
@ -139,17 +214,6 @@ export default {
this.offset = Math.max(this.offset - this.limit, 0)
}
}
},
watch: {
offset () {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData(this.url)
},
count (v) {
this.$emit('count', v)
}
}
}
</script>

View File

@ -1,16 +1,26 @@
<template>
<main class="main pusher" v-title="labels.title">
<main
v-title="labels.title"
class="main pusher"
>
<div class="ui vertical stripe segment">
<section class="ui text container">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<template v-else>
<router-link :to="{name: 'settings'}">
<translate translate-context="Content/Applications/Link">Back to settings</translate>
<translate translate-context="Content/Applications/Link">
Back to settings
</translate>
</router-link>
<h2 class="ui header">
<translate translate-context="Content/Applications/Title">Application details</translate>
<translate translate-context="Content/Applications/Title">
Application details
</translate>
</h2>
<div class="ui form">
<p>
@ -20,25 +30,45 @@
</p>
<div class="field">
<label for="copy-id"><translate translate-context="Content/Applications/Label">Application ID</translate></label>
<copy-input id="copy-id" :value="application.client_id" />
<copy-input
id="copy-id"
:value="application.client_id"
/>
</div>
<div class="field">
<label for="copy-secret"><translate translate-context="Content/Applications/Label">Application secret</translate></label>
<copy-input id="copy-secret" :value="application.client_secret" />
<copy-input
id="copy-secret"
:value="application.client_secret"
/>
</div>
<div class="field" v-if="application.token != undefined">
<div
v-if="application.token != undefined"
class="field"
>
<label for="copy-secret"><translate translate-context="Content/Applications/Label">Access token</translate></label>
<copy-input id="copy-secret" :value="application.token" />
<a href="" @click.prevent="refreshToken">
<i class="refresh icon"></i>
<copy-input
id="copy-secret"
:value="application.token"
/>
<a
href=""
@click.prevent="refreshToken"
>
<i class="refresh icon" />
<translate translate-context="Content/Applications/Label">Regenerate token</translate>
</a>
</div>
</div>
<h2 class="ui header">
<translate translate-context="Content/Applications/Title">Edit application</translate>
<translate translate-context="Content/Applications/Title">
Edit application
</translate>
</h2>
<application-form @updated="application = $event" :app="application" />
<application-form
:app="application"
@updated="application = $event"
/>
</template>
</section>
</div>
@ -46,19 +76,26 @@
</template>
<script>
import axios from "axios"
import axios from 'axios'
import ApplicationForm from "@/components/auth/ApplicationForm"
import ApplicationForm from '@/components/auth/ApplicationForm'
export default {
props: ['id'],
components: {
ApplicationForm
},
data() {
props: { id: { type: Number, required: true } },
data () {
return {
application: null,
isLoading: false,
isLoading: false
}
},
computed: {
labels () {
return {
title: this.$pgettext('Content/Applications/Title', 'Edit application')
}
}
},
created () {
@ -67,7 +104,7 @@ export default {
methods: {
fetchApplication () {
this.isLoading = true
let self = this
const self = this
axios.get(`oauth/apps/${this.id}/`).then((response) => {
self.isLoading = false
self.application = response.data
@ -78,17 +115,10 @@ export default {
},
async refreshToken () {
self.isLoading = true
let response = await axios.post(`oauth/apps/${this.id}/refresh-token`)
const response = await axios.post(`oauth/apps/${this.id}/refresh-token`)
this.application = response.data
self.isLoading = false
}
},
computed: {
labels() {
return {
title: this.$pgettext('Content/Applications/Title', "Edit application")
}
},
}
}
</script>

View File

@ -1,19 +1,45 @@
<template>
<form class="ui form component-form" role="alert" @submit.prevent="submit()">
<div v-if="errors.length > 0" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">We cannot save your changes</translate></h4>
<form
class="ui form component-form"
role="alert"
@submit.prevent="submit()"
>
<div
v-if="errors.length > 0"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
We cannot save your changes
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="ui field">
<label for="application-name"><translate translate-context="*/*/*/Noun">Name</translate></label>
<input id="application-name" name="name" required type="text" v-model="fields.name" />
<input
id="application-name"
v-model="fields.name"
name="name"
required
type="text"
>
</div>
<div class="ui field">
<label for="redirect-uris"><translate translate-context="Content/Applications/Input.Label/Noun">Redirect URI</translate></label>
<input id="redirect-uris" name="redirect_uris" type="text" v-model="fields.redirect_uris" />
<input
id="redirect-uris"
v-model="fields.redirect_uris"
name="redirect_uris"
type="text"
>
<p class="help">
<translate translate-context="Content/Applications/Help Text">
Use "urn:ietf:wg:oauth:2.0:oob" as a redirect URI if your application is not served on the web.
@ -28,13 +54,18 @@
</translate>
</p>
<div class="ui stackable two column grid">
<div v-for="parent in allScopes" class="column">
<div
v-for="(parent, key) in allScopes"
:key="key"
class="column"
>
<div class="ui parent checkbox">
<input
:id="parent.id"
v-model="scopeArray"
:value="parent.id"
:id="parent.id"
type="checkbox">
type="checkbox"
>
<label :for="parent.id">
{{ parent.label }}
<p class="help">
@ -43,13 +74,17 @@
</label>
</div>
<div v-for="child in parent.children">
<div
v-for="(child, index) in parent.children"
:key="index"
>
<div class="ui child checkbox">
<input
:id="child.id"
v-model="scopeArray"
:value="child.id"
:id="child.id"
type="checkbox">
type="checkbox"
>
<label :for="child.id">
{{ child.id }}
<p class="help">
@ -60,29 +95,43 @@
</div>
</div>
</div>
</div>
<button :class="['ui', {'loading': isLoading}, 'success', 'button']" type="submit">
<translate v-if="updating" key="2" translate-context="Content/Applications/Button.Label/Verb">Update application</translate>
<translate v-else key="3" translate-context="Content/Applications/Button.Label/Verb">Create application</translate>
</div>
<button
:class="['ui', {'loading': isLoading}, 'success', 'button']"
type="submit"
>
<translate
v-if="updating"
key="2"
translate-context="Content/Applications/Button.Label/Verb"
>
Update application
</translate>
<translate
v-else
key="3"
translate-context="Content/Applications/Button.Label/Verb"
>
Create application
</translate>
</button>
</form>
</template>
<script>
import _ from "@/lodash"
import axios from "axios"
import TranslationsMixin from "@/components/mixins/Translations"
import _ from '@/lodash'
import axios from 'axios'
import TranslationsMixin from '@/components/mixins/Translations'
export default {
mixins: [TranslationsMixin],
props: {
app: {type: Object, required: false},
defaults: {type: Object, required: false}
app: { type: Object, required: false, default: () => { return {} } },
defaults: { type: Object, required: false, default: () => { return {} } }
},
data() {
let app = this.app || {}
let defaults = this.defaults || {}
data () {
const app = this.app || {}
const defaults = this.defaults || {}
return {
isLoading: false,
errors: [],
@ -92,45 +141,19 @@ export default {
scopes: app.scopes || defaults.scopes || 'read'
},
scopes: [
{id: "profile", icon: 'user'},
{id: "libraries", icon: 'book'},
{id: "favorites", icon: 'heart'},
{id: "listenings", icon: 'music'},
{id: "follows", icon: 'users'},
{id: "playlists", icon: 'list'},
{id: "radios", icon: 'rss'},
{id: "filters", icon: 'eye slash'},
{id: "notifications", icon: 'bell'},
{id: "edits", icon: 'pencil alternate'},
{ id: 'profile', icon: 'user' },
{ id: 'libraries', icon: 'book' },
{ id: 'favorites', icon: 'heart' },
{ id: 'listenings', icon: 'music' },
{ id: 'follows', icon: 'users' },
{ id: 'playlists', icon: 'list' },
{ id: 'radios', icon: 'rss' },
{ id: 'filters', icon: 'eye slash' },
{ id: 'notifications', icon: 'bell' },
{ id: 'edits', icon: 'pencil alternate' }
]
}
},
methods: {
submit () {
this.errors = []
let self = this
self.isLoading = true
let payload = this.fields
let event, promise, message
if (this.updating) {
event = 'updated'
promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload)
} else {
event = 'created'
promise = axios.post(`oauth/apps/`, payload)
}
return promise.then(
response => {
self.isLoading = false
self.$emit(event, response.data)
},
error => {
self.isLoading = false
self.errors = error.backendErrors
}
)
},
},
computed: {
updating () {
return this.app
@ -144,8 +167,8 @@ export default {
}
},
allScopes () {
let self = this
let parents = [
const self = this
const parents = [
{
id: 'read',
label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Read'),
@ -157,19 +180,45 @@ export default {
label: this.$pgettext('Content/OAuth Scopes/Label/Verb', 'Write'),
description: this.$pgettext('Content/OAuth Scopes/Help Text', 'Write-only access to user data'),
value: this.scopeArray.indexOf('write') > -1
},
}
]
parents.forEach((p) => {
p.children = self.scopes.map(s => {
let id = `${p.id}:${s.id}`
const id = `${p.id}:${s.id}`
return {
id,
value: this.scopeArray.indexOf(id) > -1,
value: this.scopeArray.indexOf(id) > -1
}
})
})
return parents
}
},
methods: {
submit () {
this.errors = []
const self = this
self.isLoading = true
const payload = this.fields
let event, promise
if (this.updating) {
event = 'updated'
promise = axios.patch(`oauth/apps/${this.app.client_id}/`, payload)
} else {
event = 'created'
promise = axios.post('oauth/apps/', payload)
}
return promise.then(
response => {
self.isLoading = false
self.$emit(event, response.data)
},
error => {
self.isLoading = false
self.errors = error.backendErrors
}
)
}
}
}
</script>

View File

@ -1,46 +1,58 @@
<template>
<main class="main pusher" v-title="labels.title">
<main
v-title="labels.title"
class="main pusher"
>
<div class="ui vertical stripe segment">
<section class="ui text container">
<router-link :to="{name: 'settings'}">
<translate translate-context="Content/Applications/Link">Back to settings</translate>
<translate translate-context="Content/Applications/Link">
Back to settings
</translate>
</router-link>
<h2 class="ui header">
<translate translate-context="Content/Settings/Button.Label">Create a new application</translate>
<translate translate-context="Content/Settings/Button.Label">
Create a new application
</translate>
</h2>
<application-form
:defaults="defaults"
@created="$router.push({name: 'settings.applications.edit', params: {id: $event.client_id}})" />
@created="$router.push({name: 'settings.applications.edit', params: {id: $event.client_id}})"
/>
</section>
</div>
</main>
</template>
<script>
import ApplicationForm from "@/components/auth/ApplicationForm"
import ApplicationForm from '@/components/auth/ApplicationForm'
export default {
props: ['name', 'redirect_uris', 'scopes'],
components: {
ApplicationForm
},
data() {
props: {
name: { type: String, required: true },
redirectUris: { type: String, required: true },
scopes: { type: Array, required: true }
},
data () {
return {
application: null,
isLoading: false,
defaults: {
name: this.name,
redirect_uris: this.redirect_uris,
scopes: this.scopes,
redirectUris: this.redirectUris,
scopes: this.scopes
}
}
},
computed: {
labels() {
labels () {
return {
title: this.$pgettext('Content/Settings/Button.Label', "Create a new application")
title: this.$pgettext('Content/Settings/Button.Label', 'Create a new application')
}
},
}
}
}
</script>

View File

@ -1,34 +1,91 @@
<template>
<main class="main pusher" v-title="labels.title">
<main
v-title="labels.title"
class="main pusher"
>
<section class="ui vertical stripe segment">
<div class="ui small text container">
<h2><i class="lock open icon"></i><translate translate-context="Content/Auth/Title/Verb">Authorize third-party app</translate></h2>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 v-if="application" class="header"><translate translate-context="Popup/Moderation/Error message">Error while authorizing application</translate></h4>
<h4 v-else class="header"><translate translate-context="Popup/Moderation/Error message">Error while fetching application data</translate></h4>
<h2>
<i class="lock open icon" /><translate translate-context="Content/Auth/Title/Verb">
Authorize third-party app
</translate>
</h2>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4
v-if="application"
class="header"
>
<translate translate-context="Popup/Moderation/Error message">
Error while authorizing application
</translate>
</h4>
<h4
v-else
class="header"
>
<translate translate-context="Popup/Moderation/Error message">
Error while fetching application data
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<form v-else-if="application && !code" :class="['ui', {loading: isLoading}, 'form']" @submit.prevent="submit">
<h3><translate translate-context="Content/Auth/Title" :translate-params="{app: application.name}">%{ app } wants to access your Funkwhale account</translate></h3>
<form
v-else-if="application && !code"
:class="['ui', {loading: isLoading}, 'form']"
@submit.prevent="submit"
>
<h3>
<translate
translate-context="Content/Auth/Title"
:translate-params="{app: application.name}"
>
%{ app } wants to access your Funkwhale account
</translate>
</h3>
<h4 v-for="topic in topicScopes" class="ui header vertical-align">
<span v-if="topic.write && !topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']">
<i class="pencil icon"></i>
<h4
v-for="(topic, key) in topicScopes"
:key="key"
class="ui header vertical-align"
>
<span
v-if="topic.write && !topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
>
<i class="pencil icon" />
<translate translate-context="Content/Auth/Label/Noun">Write-only</translate>
</span>
<span v-else-if="!topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']">
<span
v-else-if="!topic.write && topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
>
<translate translate-context="Content/Auth/Label/Noun">Read-only</translate>
</span>
<span v-else-if="topic.write && topic.read" :class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']">
<i class="pencil icon"></i>
<span
v-else-if="topic.write && topic.read"
:class="['ui', 'basic', 'right floated', 'tiny', 'vertically-spaced component-label label']"
>
<i class="pencil icon" />
<translate translate-context="Content/Auth/Label/Noun">Full access</translate>
</span>
<i :class="[topic.icon, 'icon']"></i>
<i :class="[topic.icon, 'icon']" />
<div class="content">
{{ topic.label }}
<div class="sub header">
@ -38,23 +95,46 @@
</h4>
<div v-if="unknownRequestedScopes.length > 0">
<p><strong><translate translate-context="Content/Auth/Paragraph">The application is also requesting the following unknown permissions:</translate></strong></p>
<ul v-for="scope in unknownRequestedScopes">
<li>{{ scope }}</li>
<ul
v-for="(unknownscope, key) in unknownRequestedScopes"
:key="key"
>
<li>{{ unknownscope }}</li>
</ul>
</div>
<button class="ui success labeled icon button" type="submit">
<i class="lock open icon"></i>
<translate translate-context="Content/Signup/Button.Label/Verb" :translate-params="{app: application.name}">Authorize %{ app }</translate>
<button
class="ui success labeled icon button"
type="submit"
>
<i class="lock open icon" />
<translate
translate-context="Content/Signup/Button.Label/Verb"
:translate-params="{app: application.name}"
>
Authorize %{ app }
</translate>
</button>
<p v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'" key="1" v-translate translate-context="Content/Auth/Paragraph">
You will be shown a code to copy-paste in the application.</p>
<p v-else key="2" v-translate="{url: redirectUri}" translate-context="Content/Auth/Paragraph" :translate-params="{url: redirectUri}">You will be redirected to <strong>%{ url }</strong></p>
<p
v-if="redirectUri === 'urn:ietf:wg:oauth:2.0:oob'"
key="1"
v-translate
translate-context="Content/Auth/Paragraph"
>
You will be shown a code to copy-paste in the application.
</p>
<p
v-else
key="2"
v-translate="{url: redirectUri}"
translate-context="Content/Auth/Paragraph"
:translate-params="{url: redirectUri}"
>
You will be redirected to <strong>%{ url }</strong>
</p>
</form>
<div v-else-if="code">
<p><strong><translate translate-context="Content/Auth/Paragraph">Copy-paste the following code in the application:</translate></strong></p>
<copy-input :value="code"></copy-input>
<copy-input :value="code" />
</div>
</div>
</section>
@ -62,60 +142,54 @@
</template>
<script>
import TranslationsMixin from "@/components/mixins/Translations"
import TranslationsMixin from '@/components/mixins/Translations'
import axios from 'axios'
import {checkRedirectToLogin} from '@/utils'
import { checkRedirectToLogin } from '@/utils'
export default {
mixins: [TranslationsMixin],
props: [
'clientId',
'redirectUri',
'scope',
'responseType',
'nonce',
'state',
],
data() {
props: {
clientId: { type: String, required: true },
redirectUri: { type: String, required: true },
scope: { type: String, required: true },
responseType: { type: String, required: true },
nonce: { type: String, required: true },
state: { type: String, required: true }
},
data () {
return {
application: null,
isLoading: false,
errors: [],
code: null,
knownScopes: [
{id: "profile", icon: 'user'},
{id: "libraries", icon: 'book'},
{id: "favorites", icon: 'heart'},
{id: "listenings", icon: 'music'},
{id: "follows", icon: 'users'},
{id: "playlists", icon: 'list'},
{id: "radios", icon: 'rss'},
{id: "filters", icon: 'eye slash'},
{id: "notifications", icon: 'bell'},
{id: "edits", icon: 'pencil alternate'},
{id: "security", icon: 'lock'},
{id: "reports", icon: 'warning sign'},
{ id: 'profile', icon: 'user' },
{ id: 'libraries', icon: 'book' },
{ id: 'favorites', icon: 'heart' },
{ id: 'listenings', icon: 'music' },
{ id: 'follows', icon: 'users' },
{ id: 'playlists', icon: 'list' },
{ id: 'radios', icon: 'rss' },
{ id: 'filters', icon: 'eye slash' },
{ id: 'notifications', icon: 'bell' },
{ id: 'edits', icon: 'pencil alternate' },
{ id: 'security', icon: 'lock' },
{ id: 'reports', icon: 'warning sign' }
]
}
},
created () {
checkRedirectToLogin(this.$store, this.$router)
if (this.clientId) {
this.fetchApplication()
}
},
computed: {
labels () {
return {
title: this.$pgettext('Head/Authorize/Title', "Allow application")
title: this.$pgettext('Head/Authorize/Title', 'Allow application')
}
},
requestedScopes () {
return (this.scope || '').split(' ')
},
supportedScopes () {
let supported = ['read', 'write']
const supported = ['read', 'write']
this.knownScopes.forEach(s => {
supported.push(`read:${s.id}`)
supported.push(`write:${s.id}`)
@ -123,14 +197,14 @@ export default {
return supported
},
unknownRequestedScopes () {
let self = this
const self = this
return this.requestedScopes.filter(s => {
return self.supportedScopes.indexOf(s) < 0
})
},
topicScopes () {
let self = this
let requested = this.requestedScopes
const self = this
const requested = this.requestedScopes
let write = false
let read = false
if (requested.indexOf('read') > -1) {
@ -141,24 +215,30 @@ export default {
}
return this.knownScopes.map(s => {
let id = s.id
const id = s.id
return {
id: id,
icon: s.icon,
label: self.sharedLabels.scopes[s.id].label,
description: self.sharedLabels.scopes[s.id].description,
read: read || requested.indexOf(`read:${id}`) > -1,
write: write || requested.indexOf(`write:${id}`) > -1,
write: write || requested.indexOf(`write:${id}`) > -1
}
}).filter(c => {
return c.read || c.write
})
}
},
created () {
checkRedirectToLogin(this.$store, this.$router)
if (this.clientId) {
this.fetchApplication()
}
},
methods: {
fetchApplication () {
this.isLoading = true
let self = this
const self = this
axios.get(`oauth/apps/${this.clientId}/`).then((response) => {
self.isLoading = false
self.application = response.data
@ -169,8 +249,8 @@ export default {
},
submit () {
this.isLoading = true
let self = this
let data = new FormData();
const self = this
const data = new FormData()
data.set('redirect_uri', this.redirectUri)
data.set('scope', this.scope)
data.set('allow', true)
@ -178,7 +258,7 @@ export default {
data.set('response_type', this.responseType)
data.set('state', this.state)
data.set('nonce', this.nonce)
axios.post(`oauth/authorize/`, data, {headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'}}).then((response) => {
axios.post('oauth/authorize/', data, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' } }).then((response) => {
if (self.redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
self.isLoading = false
self.code = response.data.code

View File

@ -1,18 +1,35 @@
<template>
<form class="ui form" @submit.prevent="submit()">
<div v-if="error" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></h4>
<form
class="ui form"
@submit.prevent="submit()"
>
<div
v-if="error"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/Login/Error message.Title">
We cannot log you in
</translate>
</h4>
<ul class="list">
<li v-if="error == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
<translate translate-context="Content/Login/Error message.List item/Call to action">If you signed-up recently, you may need to wait before our moderation team review your account, or verify your e-mail address.</translate>
<translate translate-context="Content/Login/Error message.List item/Call to action">
If you signed-up recently, you may need to wait before our moderation team review your account, or verify your e-mail address.
</translate>
</li>
<li v-else-if="error == 'invalid_credentials'">
<translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check that your username and password combination is correct and make sure you verified your e-mail address.</translate>
<translate translate-context="Content/Login/Error message.List item/Call to action">
Please double-check that your username and password combination is correct and make sure you verified your e-mail address.
</translate>
</li>
<li v-else>
{{ error }}
</li>
<li v-else>{{ error }}</li>
</ul>
</div>
<template v-if="$store.getters['instance/appDomain'] === $store.getters['instance/domain']" >
<template v-if="$store.getters['instance/appDomain'] === $store.getters['instance/domain']">
<div class="field">
<label for="username-field">
<translate translate-context="Content/Login/Input.Label/Noun">Username or e-mail address</translate>
@ -24,14 +41,14 @@
</template>
</label>
<input
ref="username"
required
name="username"
type="text"
id="username-field"
autofocus
:placeholder="labels.usernamePlaceholder"
v-model="credentials.username"
id="username-field"
ref="username"
v-model="credentials.username"
required
name="username"
type="text"
autofocus
:placeholder="labels.usernamePlaceholder"
>
</div>
<div class="field">
@ -41,65 +58,78 @@
<translate translate-context="*/Login/*/Verb">Reset your password</translate>
</router-link>
</label>
<password-input field-id="password-field" required v-model="credentials.password" />
<password-input
v-model="credentials.password"
field-id="password-field"
required
/>
</div>
</template>
<template v-else>
<p>
<translate translate-context="Contant/Auth/Paragraph" :translate-params="{domain: $store.getters['instance/domain']}">You will be redirected to %{ domain } to authenticate.</translate>
<translate
translate-context="Contant/Auth/Paragraph"
:translate-params="{domain: $store.getters['instance/domain']}"
>
You will be redirected to %{ domain } to authenticate.
</translate>
</p>
</template>
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']" type="submit">
<translate translate-context="*/Login/*/Verb">Login</translate>
<button
:class="['ui', {'loading': isLoading}, 'right', 'floated', buttonClasses, 'button']"
type="submit"
>
<translate translate-context="*/Login/*/Verb">
Login
</translate>
</button>
</form>
</template>
<script>
import PasswordInput from "@/components/forms/PasswordInput"
import PasswordInput from '@/components/forms/PasswordInput'
export default {
props: {
next: { type: String, default: "/library" },
buttonClasses: { type: String, default: "success" },
showSignup: { type: Boolean, default: true},
},
components: {
PasswordInput
},
data() {
props: {
next: { type: String, default: '/library' },
buttonClasses: { type: String, default: 'success' },
showSignup: { type: Boolean, default: true }
},
data () {
return {
// We need to initialize the component with any
// properties that will be used in it
credentials: {
username: "",
password: ""
username: '',
password: ''
},
error: "",
error: '',
isLoading: false
}
},
computed: {
labels () {
const usernamePlaceholder = this.$pgettext('Content/Login/Input.Placeholder', 'Enter your username or e-mail address')
return {
usernamePlaceholder
}
}
},
created () {
if (this.$store.state.auth.authenticated) {
this.$router.push(this.next)
}
},
mounted() {
mounted () {
if (this.$refs.username) {
this.$refs.username.focus()
}
},
computed: {
labels() {
let usernamePlaceholder = this.$pgettext('Content/Login/Input.Placeholder', "Enter your username or e-mail address")
return {
usernamePlaceholder,
}
}
},
methods: {
async submit() {
async submit () {
if (this.$store.getters['instance/appDomain'] === this.$store.getters['instance/domain']) {
return await this.submitSession()
} else {
@ -107,21 +137,21 @@ export default {
await this.$store.dispatch('auth/oauthLogin', this.next)
}
},
async submitSession() {
var self = this
async submitSession () {
const self = this
self.isLoading = true
this.error = ""
var credentials = {
this.error = ''
const credentials = {
username: this.credentials.username,
password: this.credentials.password
}
this.$store
.dispatch("auth/login", {
.dispatch('auth/login', {
credentials,
next: this.next,
onError: error => {
if (error.response.status === 400) {
self.error = "invalid_credentials"
self.error = 'invalid_credentials'
} else {
self.error = error.backendErrors[0]
}

View File

@ -1,18 +1,50 @@
<template>
<main class="main pusher" v-title="labels.title">
<main
v-title="labels.title"
class="main pusher"
>
<section class="ui vertical stripe segment">
<div v-if="$store.state.auth.authenticated" class="ui small text container">
<div
v-if="$store.state.auth.authenticated"
class="ui small text container"
>
<h2>
<translate translate-context="Content/Login/Title">Are you sure you want to log out?</translate>
<translate translate-context="Content/Login/Title">
Are you sure you want to log out?
</translate>
</h2>
<p v-translate="{username: $store.state.auth.username}" translate-context="Content/Login/Paragraph">You are currently logged in as %{ username }</p>
<button class="ui button" @click="$store.dispatch('auth/logout')"><translate translate-context="Content/Login/Button.Label">Yes, log me out!</translate></button>
<p
v-translate="{username: $store.state.auth.username}"
translate-context="Content/Login/Paragraph"
>
You are currently logged in as %{ username }
</p>
<button
class="ui button"
@click="$store.dispatch('auth/logout')"
>
<translate translate-context="Content/Login/Button.Label">
Yes, log me out!
</translate>
</button>
</div>
<div v-else class="ui small text container">
<div
v-else
class="ui small text container"
>
<h2>
<translate translate-context="Content/Login/Title">You aren't currently logged in</translate>
<translate translate-context="Content/Login/Title">
You aren't currently logged in
</translate>
</h2>
<router-link to='/login' class="ui button"><translate translate-context="Content/Login/Button.Label">Log in!</translate></router-link>
<router-link
to="/login"
class="ui button"
>
<translate translate-context="Content/Login/Button.Label">
Log in!
</translate>
</router-link>
</div>
</section>
</main>
@ -21,9 +53,9 @@
<script>
export default {
computed: {
labels() {
labels () {
return {
title: this.$pgettext('Head/Login/Title', "Log Out")
title: this.$pgettext('Head/Login/Title', 'Log Out')
}
}
}

View File

@ -1,96 +1,193 @@
<template>
<form :class="['ui segment form', {loading: isLoading}]" @submit.prevent="submit">
<form
:class="['ui segment form', {loading: isLoading}]"
@submit.prevent="submit"
>
<h3>{{ plugin.label }}</h3>
<div v-if="plugin.description" v-html="markdown.makeHtml(plugin.description)"></div>
<template v-if="plugin.homepage" >
<div class="ui small hidden divider"></div>
<a :href="plugin.homepage" target="_blank">
<i class="external icon"></i>
<div
v-if="plugin.description"
v-html="markdown.makeHtml(plugin.description)"
/>
<template v-if="plugin.homepage">
<div class="ui small hidden divider" />
<a
:href="plugin.homepage"
target="_blank"
>
<i class="external icon" />
<translate translate-context="Footer/*/List item.Link/Short, Noun">Documentation</translate>
</a>
</template>
<div class="ui clearing hidden divider"></div>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving plugin</translate></h4>
<div class="ui clearing hidden divider" />
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while saving plugin
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input :id="`${plugin.name}-enabled`" type="checkbox" v-model="enabled" />
<input
:id="`${plugin.name}-enabled`"
v-model="enabled"
type="checkbox"
>
<label :for="`${plugin.name}-enabled`"><translate translate-context="*/*/*">Enabled</translate></label>
</div>
</div>
<div class="ui clearing hidden divider"></div>
<div v-if="plugin.source" class="field">
<div class="ui clearing hidden divider" />
<div
v-if="plugin.source"
class="field"
>
<label for="plugin-library"><translate translate-context="*/*/*/Noun">Library</translate></label>
<select id="plugin-library" v-model="values['library']">
<option :value="l.uuid" v-for="l in libraries" :key="l.uuid">{{ l.name }}</option>
<select
id="plugin-library"
v-model="values['library']"
>
<option
v-for="l in libraries"
:key="l.uuid"
:value="l.uuid"
>
{{ l.name }}
</option>
</select>
<div>
<translate translate-context="*/*/Paragraph/Noun">Library where files should be imported.</translate>
<translate translate-context="*/*/Paragraph/Noun">
Library where files should be imported.
</translate>
</div>
</div>
<template v-if="plugin.conf && plugin.conf.length > 0" v-for="field in plugin.conf">
<div v-if="field.type === 'text'" class="field">
<template
v-for="(field, key) in plugin.conf"
v-if="plugin.conf && plugin.conf.length > 0"
>
<div
v-if="field.type === 'text'"
:key="key"
class="field"
>
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="text" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
<input
:id="`plugin-${field.name}`"
v-model="values[field.name]"
type="text"
>
<div
v-if="field.help"
v-html="markdown.makeHtml(field.help)"
/>
</div>
<div v-if="field.type === 'long_text'" class="field">
<div
v-if="field.type === 'long_text'"
:key="key"
class="field"
>
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<textarea :id="`plugin-${field.name}`" type="text" v-model="values[field.name]" rows="5" />
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
<textarea
:id="`plugin-${field.name}`"
v-model="values[field.name]"
type="text"
rows="5"
/>
<div
v-if="field.help"
v-html="markdown.makeHtml(field.help)"
/>
</div>
<div v-if="field.type === 'url'" class="field">
<div
v-if="field.type === 'url'"
:key="key"
class="field"
>
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="url" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
<input
:id="`plugin-${field.name}`"
v-model="values[field.name]"
type="url"
>
<div
v-if="field.help"
v-html="markdown.makeHtml(field.help)"
/>
</div>
<div v-if="field.type === 'password'" class="field">
<div
v-if="field.type === 'password'"
:key="key"
class="field"
>
<label :for="`plugin-${field.name}`">{{ field.label || field.name }}</label>
<input :id="`plugin-${field.name}`" type="password" v-model="values[field.name]">
<div v-if="field.help" v-html="markdown.makeHtml(field.help)"></div>
<input
:id="`plugin-${field.name}`"
v-model="values[field.name]"
type="password"
>
<div
v-if="field.help"
v-html="markdown.makeHtml(field.help)"
/>
</div>
</template>
<button
type="submit"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Save</translate>
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
>
<translate translate-context="Content/*/Button.Label/Verb">
Save
</translate>
</button>
<button
type="scan"
v-if="plugin.source"
type="scan"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
@click.prevent="submitAndScan"
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']">
<translate translate-context="Content/*/Button.Label/Verb">Scan</translate>
>
<translate translate-context="Content/*/Button.Label/Verb">
Scan
</translate>
</button>
<div class="ui clearing hidden divider"></div>
<div class="ui clearing hidden divider" />
</form>
</template>
<script>
import axios from "axios"
import axios from 'axios'
import lodash from '@/lodash'
import showdown from 'showdown'
export default {
props: ['plugin', "libraries"],
props: {
plugin: { type: Object, required: true },
libraries: { type: Array, required: true }
},
data () {
return {
markdown: new showdown.Converter(),
isLoading: false,
enabled: this.plugin.enabled,
values: lodash.clone(this.plugin.values || {}),
errors: [],
errors: []
}
},
methods: {
async submit () {
this.isLoading = true
this.errors = []
let url = `plugins/${this.plugin.name}`
let enableUrl = this.enabled ? `${url}/enable` : `${url}/disable`
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)
@ -101,19 +198,19 @@ export default {
},
async scan () {
this.isLoading = true
this.errors = []
let url = `plugins/${this.plugin.name}/scan`
try {
await axios.post(url, this.values)
} catch (e) {
this.errors = e.backendErrors
}
this.isLoading = false
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>

View File

@ -2,140 +2,197 @@
<div v-if="submitted">
<div class="ui success message">
<p v-if="signupRequiresApproval">
<translate translate-context="Content/Signup/Form/Paragraph">Your account request was successfully submitted. You will be notified by e-mail when our moderation team has reviewed your request.</translate>
<translate translate-context="Content/Signup/Form/Paragraph">
Your account request was successfully submitted. You will be notified by e-mail when our moderation team has reviewed your request.
</translate>
</p>
<p v-else>
<translate translate-context="Content/Signup/Form/Paragraph">Your account was successfully created. Please verify your e-mail address before trying to login.</translate>
<translate translate-context="Content/Signup/Form/Paragraph">
Your account was successfully created. Please verify your e-mail address before trying to login.
</translate>
</p>
</div>
<h2><translate translate-context="Content/Login/Title/Verb">Log in to your Funkwhale account</translate></h2>
<login-form button-classes="basic success" :show-signup="false"></login-form>
<h2>
<translate translate-context="Content/Login/Title/Verb">
Log in to your Funkwhale account
</translate>
</h2>
<login-form
button-classes="basic success"
:show-signup="false"
/>
</div>
<form
v-else
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
@submit.prevent="submit()">
<p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
<translate translate-context="Content/Signup/Form/Paragraph">Public registrations are not possible on this instance. You will need an invitation code to sign up.</translate>
@submit.prevent="submit()"
>
<p
v-if="!$store.state.instance.settings.users.registration_enabled.value"
class="ui message"
>
<translate translate-context="Content/Signup/Form/Paragraph">
Public registrations are not possible on this instance. You will need an invitation code to sign up.
</translate>
</p>
<p class="ui message" v-else-if="signupRequiresApproval">
<translate translate-context="Content/Signup/Form/Paragraph">Registrations on this pod are open, but reviewed by moderators before approval.</translate>
<p
v-else-if="signupRequiresApproval"
class="ui message"
>
<translate translate-context="Content/Signup/Form/Paragraph">
Registrations on this pod are open, but reviewed by moderators before approval.
</translate>
</p>
<template v-if="formCustomization && formCustomization.help_text">
<rendered-description :content="formCustomization.help_text" :fetch-html="fetchDescriptionHtml" :permissive="true"></rendered-description>
<div class="ui hidden divider"></div>
<rendered-description
:content="formCustomization.help_text"
:fetch-html="fetchDescriptionHtml"
:permissive="true"
/>
<div class="ui hidden divider" />
</template>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></h4>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/Signup/Form/Paragraph">
Your account cannot be created.
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label for="username-field"><translate translate-context="Content/*/*">Username</translate></label>
<input
ref="username"
name="username"
required
id="username-field"
type="text"
autofocus
:placeholder="labels.usernamePlaceholder"
v-model="username">
id="username-field"
ref="username"
v-model="username"
name="username"
required
type="text"
autofocus
:placeholder="labels.usernamePlaceholder"
>
</div>
<div class="required field">
<label for="email-field"><translate translate-context="Content/*/*/Noun">E-mail address</translate></label>
<input
id="email-field"
ref="email"
name="email"
required
type="email"
:placeholder="labels.emailPlaceholder"
v-model="email">
id="email-field"
ref="email"
v-model="email"
name="email"
required
type="email"
:placeholder="labels.emailPlaceholder"
>
</div>
<div class="required field">
<label for="password-field"><translate translate-context="*/*/*">Password</translate></label>
<password-input field-id="password-field" v-model="password" />
<password-input
v-model="password"
field-id="password-field"
/>
</div>
<div class="required field" v-if="!$store.state.instance.settings.users.registration_enabled.value">
<div
v-if="!$store.state.instance.settings.users.registration_enabled.value"
class="required field"
>
<label for="invitation-code"><translate translate-context="Content/*/Input.Label">Invitation code</translate></label>
<input
id="invitation-code"
required
type="text"
name="invitation"
:placeholder="labels.placeholder"
v-model="invitation">
id="invitation-code"
v-model="invitation"
required
type="text"
name="invitation"
:placeholder="labels.placeholder"
>
</div>
<template v-if="signupRequiresApproval && formCustomization && formCustomization.fields && formCustomization.fields.length > 0">
<div :class="[{required: field.required}, 'field']" v-for="(field, idx) in formCustomization.fields" :key="idx">
<div
v-for="(field, idx) in formCustomization.fields"
:key="idx"
:class="[{required: field.required}, 'field']"
>
<label :for="`custom-field-${idx}`">{{ field.label }}</label>
<textarea
v-if="field.input_type === 'long_text'"
:id="`custom-field-${idx}`"
:value="customFields[field.label]"
:required="field.required"
@input="$set(customFields, field.label, $event.target.value)" rows="5"></textarea>
<input v-else :id="`custom-field-${idx}`" type="text" :value="customFields[field.label]" :required="field.required" @input="$set(customFields, field.label, $event.target.value)">
rows="5"
@input="$set(customFields, field.label, $event.target.value)"
/>
<input
v-else
:id="`custom-field-${idx}`"
type="text"
:value="customFields[field.label]"
:required="field.required"
@input="$set(customFields, field.label, $event.target.value)"
>
</div>
</template>
<button :class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" type="submit">
<translate translate-context="Content/Signup/Button.Label">Create my account</translate>
<button
:class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']"
type="submit"
>
<translate translate-context="Content/Signup/Button.Label">
Create my account
</translate>
</button>
</form>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import axios from 'axios'
import logger from '@/logging'
import LoginForm from "@/components/auth/LoginForm"
import PasswordInput from "@/components/forms/PasswordInput"
import LoginForm from '@/components/auth/LoginForm'
import PasswordInput from '@/components/forms/PasswordInput'
export default {
props: {
defaultInvitation: { type: String, required: false, default: null },
next: { type: String, default: "/" },
buttonClasses: { type: String, default: "success" },
customization: { type: Object, default: null},
fetchDescriptionHtml: { type: Boolean, default: false},
fetchDescriptionHtml: { type: Boolean, default: false},
signupApprovalEnabled: {type: Boolean, default: null, required: false},
},
components: {
LoginForm,
PasswordInput,
PasswordInput
},
data() {
props: {
defaultInvitation: { type: String, required: false, default: null },
next: { type: String, default: '/' },
buttonClasses: { type: String, default: 'success' },
customization: { type: Object, default: null },
fetchDescriptionHtml: { type: Boolean, default: false },
signupApprovalEnabled: { type: Boolean, default: null, required: false }
},
data () {
return {
username: "",
email: "",
password: "",
username: '',
email: '',
password: '',
isLoadingInstanceSetting: true,
errors: [],
isLoading: false,
invitation: this.defaultInvitation,
customFields: {},
submitted: false,
submitted: false
}
},
created() {
let self = this
this.$store.dispatch("instance/fetchSettings", {
callback: function() {
self.isLoadingInstanceSetting = false
}
})
},
computed: {
labels() {
let placeholder = this.$pgettext(
"Content/Signup/Form/Placeholder",
"Enter your invitation code (case insensitive)"
labels () {
const placeholder = this.$pgettext(
'Content/Signup/Form/Placeholder',
'Enter your invitation code (case insensitive)'
)
let usernamePlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your username")
let emailPlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your e-mail address")
const usernamePlaceholder = this.$pgettext('Content/Signup/Form/Placeholder', 'Enter your username')
const emailPlaceholder = this.$pgettext('Content/Signup/Form/Placeholder', 'Enter your e-mail address')
return {
usernamePlaceholder,
emailPlaceholder,
@ -152,22 +209,30 @@ export default {
return this.signupApprovalEnabled
}
},
created () {
const self = this
this.$store.dispatch('instance/fetchSettings', {
callback: function () {
self.isLoadingInstanceSetting = false
}
})
},
methods: {
submit() {
var self = this
submit () {
const self = this
self.isLoading = true
this.errors = []
var payload = {
const payload = {
username: this.username,
password1: this.password,
password2: this.password,
email: this.email,
invitation: this.invitation,
request_fields: this.customFields,
request_fields: this.customFields
}
return axios.post("auth/registration/", payload).then(
return axios.post('auth/registration/', payload).then(
response => {
logger.default.info("Successfully created account")
logger.default.info('Successfully created account')
self.submitted = true
self.isLoading = false
},

View File

@ -1,61 +1,144 @@
<template>
<form class="ui form" @submit.prevent="requestNewToken()">
<h2><translate translate-context="Content/Settings/Title">Subsonic API password</translate></h2>
<p class="ui message" v-if="!subsonicEnabled">
<translate translate-context="Content/Settings/Paragraph">The Subsonic API is not available on this Funkwhale instance.</translate>
<form
class="ui form"
@submit.prevent="requestNewToken()"
>
<h2>
<translate translate-context="Content/Settings/Title">
Subsonic API password
</translate>
</h2>
<p
v-if="!subsonicEnabled"
class="ui message"
>
<translate translate-context="Content/Settings/Paragraph">
The Subsonic API is not available on this Funkwhale instance.
</translate>
</p>
<p>
<translate translate-context="Content/Settings/Paragraph'">Funkwhale is compatible with other music players that support the Subsonic API.</translate>&nbsp;<translate translate-context="Content/Settings/Paragraph">You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.</translate>
<translate translate-context="Content/Settings/Paragraph'">
Funkwhale is compatible with other music players that support the Subsonic API.
</translate>&nbsp;<translate translate-context="Content/Settings/Paragraph">
You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.
</translate>
</p>
<p>
<translate translate-context="Content/Settings/Paragraph">However, accessing Funkwhale from those clients requires a separate password you can set below.</translate>
<translate translate-context="Content/Settings/Paragraph">
However, accessing Funkwhale from those clients requires a separate password you can set below.
</translate>
</p>
<p><a href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients" target="_blank">
<translate translate-context="Content/Settings/Link">Discover how to use Funkwhale from other apps</translate>
</a></p>
<div v-if="success" class="ui positive message">
<h4 class="header">{{ successMessage }}</h4>
<p>
<a
href="https://docs.funkwhale.audio/users/apps.html#subsonic-compatible-clients"
target="_blank"
>
<translate translate-context="Content/Settings/Link">Discover how to use Funkwhale from other apps</translate>
</a>
</p>
<div
v-if="success"
class="ui positive message"
>
<h4 class="header">
{{ successMessage }}
</h4>
</div>
<div v-if="subsonicEnabled && errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error</translate></h4>
<div
v-if="subsonicEnabled && errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<template v-if="subsonicEnabled">
<div v-if="token" class="field">
<label for="subsonic-password" class="visually-hidden">{{ labels.subsonicField }}</label>
<div
v-if="token"
class="field"
>
<label
for="subsonic-password"
class="visually-hidden"
>{{ labels.subsonicField }}</label>
<password-input
field-id="subsonic-password"
ref="passwordInput"
v-model="token"
:key="token"
v-model="token"
field-id="subsonic-password"
:copy-button="true"
:default-show="showToken"/>
:default-show="showToken"
/>
</div>
<dangerous-button
v-if="token"
:class="['ui', {'loading': isLoading}, 'button']"
:action="requestNewToken">
<translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate>
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Request a new Subsonic API password?</translate></p>
<p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will log you out from existing devices that use the current password.</translate></p>
<div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Request a new password</translate></div>
:action="requestNewToken"
>
<translate translate-context="*/Settings/Button.Label/Verb">
Request a new password
</translate>
<p slot="modal-header">
<translate translate-context="Popup/Settings/Title">
Request a new Subsonic API password?
</translate>
</p>
<p slot="modal-content">
<translate translate-context="Popup/Settings/Paragraph">
This will log you out from existing devices that use the current password.
</translate>
</p>
<div slot="modal-confirm">
<translate translate-context="*/Settings/Button.Label/Verb">
Request a new password
</translate>
</div>
</dangerous-button>
<button
v-else
color=""
:class="['ui', {'loading': isLoading}, 'button']"
@click="requestNewToken"><translate translate-context="Content/Settings/Button.Label/Verb">Request a password</translate></button>
<dangerous-button
v-if="token"
:class="['ui', {'loading': isLoading}, 'warning', 'button']"
:action="disable">
<translate translate-context="Content/Settings/Button.Label/Verb">Disable Subsonic access</translate>
<p slot="modal-header"><translate translate-context="Popup/Settings/Title">Disable Subsonic API access?</translate></p>
<p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will completely disable access to the Subsonic API using from account.</translate></p>
<div slot="modal-confirm"><translate translate-context="Popup/Settings/Button.Label">Disable access</translate></div>
</dangerous-button>
@click="requestNewToken"
>
<translate translate-context="Content/Settings/Button.Label/Verb">
Request a password
</translate>
</button>
<dangerous-button
v-if="token"
:class="['ui', {'loading': isLoading}, 'warning', 'button']"
:action="disable"
>
<translate translate-context="Content/Settings/Button.Label/Verb">
Disable Subsonic access
</translate>
<p slot="modal-header">
<translate translate-context="Popup/Settings/Title">
Disable Subsonic API access?
</translate>
</p>
<p slot="modal-content">
<translate translate-context="Popup/Settings/Paragraph">
This will completely disable access to the Subsonic API using from account.
</translate>
</p>
<div slot="modal-confirm">
<translate translate-context="Popup/Settings/Button.Label">
Disable access
</translate>
</div>
</dangerous-button>
</template>
</form>
</template>
@ -78,6 +161,16 @@ export default {
showToken: false
}
},
computed: {
subsonicEnabled () {
return this.$store.state.instance.settings.subsonic.enabled.value
},
labels () {
return {
subsonicField: this.$pgettext('Content/Password/Input.label', 'Your subsonic API password')
}
}
},
created () {
this.fetchToken()
},
@ -86,10 +179,10 @@ export default {
this.success = false
this.errors = []
this.isLoading = true
let self = this
let url = `users/${this.$store.state.auth.username}/subsonic-token/`
const self = this
const url = `users/${this.$store.state.auth.username}/subsonic-token/`
return axios.get(url).then(response => {
self.token = response.data['subsonic_api_token']
self.token = response.data.subsonic_api_token
self.isLoading = false
}, error => {
self.isLoading = false
@ -101,11 +194,11 @@ export default {
this.success = false
this.errors = []
this.isLoading = true
let self = this
let url = `users/${this.$store.state.auth.username}/subsonic-token/`
const self = this
const url = `users/${this.$store.state.auth.username}/subsonic-token/`
return axios.post(url, {}).then(response => {
self.showToken = true
self.token = response.data['subsonic_api_token']
self.token = response.data.subsonic_api_token
self.isLoading = false
self.success = true
}, error => {
@ -118,8 +211,8 @@ export default {
this.success = false
this.errors = []
this.isLoading = true
let self = this
let url = `users/${this.$store.state.auth.username}/subsonic-token/`
const self = this
const url = `users/${this.$store.state.auth.username}/subsonic-token/`
return axios.delete(url).then(response => {
self.isLoading = false
self.token = null
@ -129,16 +222,6 @@ export default {
self.errors = error.backendErrors
})
}
},
computed: {
subsonicEnabled () {
return this.$store.state.instance.settings.subsonic.enabled.value
},
labels () {
return {
subsonicField: this.$pgettext("Content/Password/Input.label", "Your subsonic API password")
}
}
}
}
</script>

View File

@ -1,16 +1,35 @@
<template>
<form @submit.stop.prevent :class="['ui', {loading: isLoading}, 'form']">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while creating</translate></h4>
<form
:class="['ui', {loading: isLoading}, 'form']"
@submit.stop.prevent
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while creating
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="ui required field">
<label for="album-title">
<translate translate-context="*/*/*/Noun">Title</translate>
</label>
<input type="text" v-model="values.title">
<input
v-model="values.title"
type="text"
>
</div>
</form>
</template>
@ -18,17 +37,17 @@
import axios from 'axios'
export default {
props: {
channel: {type: Object, required: true},
},
components: {},
props: {
channel: { type: Object, required: true }
},
data () {
return {
errors: [],
isLoading: false,
values: {
title: "",
},
title: ''
}
}
},
computed: {
@ -36,20 +55,28 @@ export default {
return this.values.title.length > 0
}
},
watch: {
submittable (v) {
this.$emit('submittable', v)
},
isLoading (v) {
this.$emit('loading', v)
}
},
methods: {
submit () {
let self = this
const self = this
self.isLoading = true
self.errors = []
let payload = {
const payload = {
...this.values,
artist: this.channel.artist.id,
artist: this.channel.artist.id
}
return axios.post('albums/', payload).then(
response => {
self.isLoading = false
self.$emit("created")
self.$emit('created')
},
error => {
self.errors = error.backendErrors
@ -57,14 +84,6 @@ export default {
}
)
}
},
watch: {
submittable (v) {
this.$emit("submittable", v)
},
isLoading (v) {
this.$emit("loading", v)
}
}
}
</script>

View File

@ -1,21 +1,47 @@
<template>
<modal class="small" :show.sync="show">
<modal
class="small"
:show.sync="show"
>
<h4 class="header">
<translate key="1" v-if="channel.content_category === 'podcasts'" translate-context="Popup/Channels/Title/Verb">New series</translate>
<translate key="2" v-else translate-context="Popup/Channels/Title">New album</translate>
<translate
v-if="channel.content_category === 'podcasts'"
key="1"
translate-context="Popup/Channels/Title/Verb"
>
New series
</translate>
<translate
v-else
key="2"
translate-context="Popup/Channels/Title"
>
New album
</translate>
</h4>
<div class="scrolling content">
<channel-album-form
ref="albumForm"
:channel="channel"
@loading="isLoading = $event"
@submittable="submittable = $event"
@created="$emit('created', $event)"
:channel="channel"></channel-album-form>
/>
</div>
<div class="actions">
<button class="ui basic cancel button"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
<button :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="!submittable" @click.stop.prevent="$refs.albumForm.submit()">
<translate translate-context="*/*/Button.Label">Create</translate>
<button class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">
Cancel
</translate>
</button>
<button
:class="['ui', 'primary', {loading: isLoading}, 'button']"
:disabled="!submittable"
@click.stop.prevent="$refs.albumForm.submit()"
>
<translate translate-context="*/*/Button.Label">
Create
</translate>
</button>
</div>
</modal>
@ -26,16 +52,16 @@ import Modal from '@/components/semantic/Modal'
import ChannelAlbumForm from '@/components/channels/AlbumForm'
export default {
props: ['channel'],
components: {
Modal,
ChannelAlbumForm
},
props: { channel: { type: Object, required: true } },
data () {
return {
isLoading: false,
submittable: false,
show: false,
show: false
}
},
watch: {

View File

@ -1,15 +1,41 @@
<template>
<div>
<label for="album-dropdown">
<translate v-if="channel && channel.artist.content_category === 'podcast'" key="1" translate-context="*/*/*">Series</translate>
<translate v-else key="2" translate-context="*/*/*">Album</translate>
<translate
v-if="channel && channel.artist.content_category === 'podcast'"
key="1"
translate-context="*/*/*"
>Series</translate>
<translate
v-else
key="2"
translate-context="*/*/*"
>Album</translate>
</label>
<select id="album-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
<select
id="album-dropdown"
:value="value"
class="ui search normal dropdown"
@input="$emit('input', $event.target.value)"
>
<option value="">
<translate translate-context="*/*/*">None</translate>
<translate translate-context="*/*/*">
None
</translate>
</option>
<option v-for="album in albums" :key="album.id" :value="album.id">
{{ album.title }} (<translate translate-context="*/*/*" :translate-params="{count: album.tracks_count}" :translate-n="album.tracks_count" translate-plural="%{ count } tracks">%{ count } track</translate>)
<option
v-for="album in albums"
:key="album.id"
:value="album.id"
>
{{ album.title }} (<translate
translate-context="*/*/*"
:translate-params="{count: album.tracks_count}"
:translate-n="album.tracks_count"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate>)
</option>
</select>
</div>
@ -18,11 +44,19 @@
import axios from 'axios'
export default {
props: ['value', 'channel'],
props: {
value: { type: String, required: true },
channel: { type: Object, required: true }
},
data () {
return {
albums: [],
isLoading: false,
isLoading: false
}
},
watch: {
async channel () {
await this.fetchData()
}
},
async created () {
@ -35,14 +69,9 @@ export default {
return
}
this.isLoading = true
let response = await axios.get('albums/', {params: {artist: this.channel.artist.id, include_channels: 'true'}})
const response = await axios.get('albums/', { params: { artist: this.channel.artist.id, include_channels: 'true' } })
this.albums = response.data.results
this.isLoading = false
},
},
watch: {
async channel () {
await this.fetchData()
}
}
}

View File

@ -3,15 +3,36 @@
<label for="license-dropdown">
<translate translate-context="Content/*/*/Noun">License</translate>
</label>
<select id="license-dropdown" :value="value" @input="$emit('input', $event.target.value)" class="ui search normal dropdown">
<select
id="license-dropdown"
:value="value"
class="ui search normal dropdown"
@input="$emit('input', $event.target.value)"
>
<option value="">
<translate translate-context="*/*/*">None</translate>
<translate translate-context="*/*/*">
None
</translate>
</option>
<option
v-for="l in featuredLicenses"
:key="l.code"
:value="l.code"
>
{{ l.name }}
</option>
<option v-for="l in featuredLicenses" :key="l.code" :value="l.code">{{ l.name }}</option>
</select>
<p class="help" v-if="value">
<div class="ui very small hidden divider"></div>
<a :href="currentLicense.url" v-if="value" target="_blank" rel="noreferrer noopener">
<div class="ui very small hidden divider" />
<p
v-if="value"
class="help"
>
<a
v-if="value"
:href="currentLicense.url"
target="_blank"
rel="noreferrer noopener"
>
<translate translate-context="Content/*/*">About this license</translate>
</a>
</p>
@ -21,7 +42,7 @@
import axios from 'axios'
export default {
props: ['value'],
props: { value: { type: String, required: true } },
data () {
return {
availableLicenses: [],
@ -32,38 +53,38 @@ export default {
'cc-by-nc-4.0',
'cc-by-nc-sa-4.0',
'cc-by-nc-nd-4.0',
'cc-by-nd-4.0',
'cc-by-nd-4.0'
],
isLoading: false,
isLoading: false
}
},
async created () {
await this.fetchLicenses()
},
computed: {
featuredLicenses () {
let self = this
const self = this
return this.availableLicenses.filter((l) => {
return self.featuredLicensesIds.indexOf(l.code) > -1
})
},
currentLicense () {
let self = this
const self = this
if (this.value) {
return this.availableLicenses.filter((l) => {
return l.code === self.value
})[0]
}
return null
}
},
async created () {
await this.fetchLicenses()
},
methods: {
async fetchLicenses () {
this.isLoading = true
let response = await axios.get('licenses/')
const response = await axios.get('licenses/')
this.availableLicenses = response.data.results
this.isLoading = false
},
},
}
}
}
</script>

View File

@ -1,20 +1,40 @@
<template>
<button v-if="$store.state.auth.authenticated" @click.stop="toggle" :class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isSubscribed" translate-context="Content/Track/Button.Message">Unsubscribe</translate>
<translate v-else translate-context="Content/Track/*/Verb">Subscribe</translate>
<template>
<button
v-if="$store.state.auth.authenticated"
:class="['ui', 'pink', {'inverted': isSubscribed}, {'favorited': isSubscribed}, 'icon', 'labeled', 'button']"
@click.stop="toggle"
>
<i class="heart icon" />
<translate
v-if="isSubscribed"
translate-context="Content/Track/Button.Message"
>
Unsubscribe
</translate>
<translate
v-else
translate-context="Content/Track/*/Verb"
>
Subscribe
</translate>
</button>
<button @click="$refs.loginModal.show = true" v-else :class="['ui', 'pink', 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate translate-context="Content/Track/*/Verb">Subscribe</translate>
<button
v-else
:class="['ui', 'pink', 'icon', 'labeled', 'button']"
@click="$refs.loginModal.show = true"
>
<i class="heart icon" />
<translate translate-context="Content/Track/*/Verb">
Subscribe
</translate>
<login-modal
ref="loginModal"
class="small"
:nextRoute='this.$route.fullPath'
:message='this.message.authMessage'
:cover='this.channel.artist.cover'
@created="$refs.loginModal.show = false;">
</login-modal>
:next-route="$route.fullPath"
:message="message.authMessage"
:cover="channel.artist.cover"
@created="$refs.loginModal.show = false;"
/>
</button>
</template>
@ -22,12 +42,12 @@
import LoginModal from '@/components/common/LoginModal'
export default {
props: {
channel: {type: Object},
},
components: {
LoginModal
},
props: {
channel: { type: Object, required: true }
},
computed: {
title () {
if (this.isSubscribed) {
@ -40,10 +60,10 @@ export default {
return this.$store.getters['channels/isSubscribed'](this.channel.uuid)
},
message () {
return {
return {
authMessage: this.$pgettext('Popup/Message/Paragraph', 'You need to be logged in to subscribe to this channel')
}
},
}
},
methods: {
toggle () {
@ -56,6 +76,5 @@ export default {
}
}
}
</script>

View File

@ -1,70 +1,132 @@
<template>
<form @submit.stop.prevent :class="['ui', {loading: isLoadingStep1}, 'form component-file-upload']">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while publishing</translate></h4>
<form
:class="['ui', {loading: isLoadingStep1}, 'form component-file-upload']"
@submit.stop.prevent
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while publishing
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div :class="['ui', 'required', {hidden: step > 1}, 'field']">
<label for="channel-dropdown">
<translate translate-context="*/*/*">Channel</translate>
</label>
<div id="channel-dropdown" class="ui search normal selection dropdown">
<div class="text"></div>
<i class="dropdown icon"></i>
<div
id="channel-dropdown"
class="ui search normal selection dropdown"
>
<div class="text" />
<i class="dropdown icon" />
</div>
</div>
<album-select v-model.number="values.album" :channel="selectedChannel" :class="['ui', {hidden: step > 1}, 'field']"></album-select>
<license-select v-model="values.license" :class="['ui', {hidden: step > 1}, 'field']"></license-select>
<album-select
v-model.number="values.album"
:channel="selectedChannel"
:class="['ui', {hidden: step > 1}, 'field']"
/>
<license-select
v-model="values.license"
:class="['ui', {hidden: step > 1}, 'field']"
/>
<div :class="['ui', {hidden: step > 1}, 'message']">
<div class="content">
<p>
<i class="copyright icon"></i>
<translate translate-context="Content/Channels/Popup.Paragraph">Add a license to your upload to ensure some freedoms to your public.</translate>
<i class="copyright icon" />
<translate translate-context="Content/Channels/Popup.Paragraph">
Add a license to your upload to ensure some freedoms to your public.
</translate>
</p>
</div>
</div>
<template v-if="step >= 2 && step < 4">
<div role="alert" class="ui warning message" v-if="remainingSpace === 0">
<div
v-if="remainingSpace === 0"
role="alert"
class="ui warning message"
>
<div class="content">
<p>
<i class="warning icon"></i>
<translate translate-context="Content/Library/Paragraph">You don't have any space left to upload your files. Please contact the moderators.</translate>
<i class="warning icon" />
<translate translate-context="Content/Library/Paragraph">
You don't have any space left to upload your files. Please contact the moderators.
</translate>
</p>
</div>
</div>
<template v-else>
<div class="ui visible info message" v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null">
<div
v-if="step === 2 && draftUploads && draftUploads.length > 0 && includeDraftUploads === null"
class="ui visible info message"
>
<p>
<i class="redo icon"></i>
<translate translate-context="Popup/Channels/Paragraph">You have some draft uploads pending publication.</translate>
<i class="redo icon" />
<translate translate-context="Popup/Channels/Paragraph">
You have some draft uploads pending publication.
</translate>
</p>
<button @click.stop.prevent="includeDraftUploads = false" class="ui basic button">
<translate translate-context="*/*/*">Ignore</translate>
<button
class="ui basic button"
@click.stop.prevent="includeDraftUploads = false"
>
<translate translate-context="*/*/*">
Ignore
</translate>
</button>
<button @click.stop.prevent="includeDraftUploads = true" class="ui basic button">
<translate translate-context="*/*/*">Resume</translate>
<button
class="ui basic button"
@click.stop.prevent="includeDraftUploads = true"
>
<translate translate-context="*/*/*">
Resume
</translate>
</button>
</div>
<div v-if="uploadedFiles.length > 0" :class="[{hidden: step === 3}]">
<div class="channel-file" v-for="(file, idx) in uploadedFiles">
<div
v-if="uploadedFiles.length > 0"
:class="[{hidden: step === 3}]"
>
<div
v-for="(file, idx) in uploadedFiles"
:key="idx"
class="channel-file"
>
<div class="content">
<div role="button"
<div
v-if="file.response.uuid"
@click.stop.prevent="selectedUploadId = file.response.uuid"
role="button"
class="ui basic icon button"
:title="labels.editTitle">
<i class="pencil icon"></i>
:title="labels.editTitle"
@click.stop.prevent="selectedUploadId = file.response.uuid"
>
<i class="pencil icon" />
</div>
<div
v-if="file.error"
@click.stop.prevent="selectedUploadId = file.response.uuid"
class="ui basic danger icon label"
:title="file.error">
<i class="warning sign icon"></i>
:title="file.error"
@click.stop.prevent="selectedUploadId = file.response.uuid"
>
<i class="warning sign icon" />
</div>
<div v-else-if="file.active" class="ui active slow inline loader"></div>
<div
v-else-if="file.active"
class="ui active slow inline loader"
/>
</div>
<h4 class="ui header">
<template v-if="file.metadata.title">
@ -77,20 +139,39 @@
<template v-if="file.response.uuid">
{{ file.size | humanSize }}
<template v-if="file.response.duration">
· <human-duration :duration="file.response.duration"></human-duration>
· <human-duration :duration="file.response.duration" />
</template>
</template>
<template v-else>
<translate key="1" v-if="file.active" translate-context="Channels/*/*">Uploading</translate>
<translate key="2" v-else-if="file.error" translate-context="Channels/*/*">Errored</translate>
<translate key="3" v-else translate-context="Channels/*/*">Pending</translate>
<translate
v-if="file.active"
key="1"
translate-context="Channels/*/*"
>
Uploading
</translate>
<translate
v-else-if="file.error"
key="2"
translate-context="Channels/*/*"
>
Errored
</translate>
<translate
v-else
key="3"
translate-context="Channels/*/*"
>
Pending
</translate>
· {{ file.size | humanSize }}
· {{ parseInt(file.progress) }}%
</template>
· <a @click.stop.prevent="remove(file)">
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
</a>
<template v-if="file.error"> ·
<template v-if="file.error">
·
<a @click.stop.prevent="retry(file)">
<translate translate-context="*/*/*">Retry</translate>
</a>
@ -100,20 +181,30 @@
</div>
</div>
<upload-metadata-form
:key="selectedUploadId"
v-if="selectedUpload"
:key="selectedUploadId"
:upload="selectedUpload"
:values="uploadImportData[selectedUploadId]"
@values="setDynamic('uploadImportData', selectedUploadId, $event)"></upload-metadata-form>
<div class="ui message" v-if="step === 2">
@values="setDynamic('uploadImportData', selectedUploadId, $event)"
/>
<div
v-if="step === 2"
class="ui message"
>
<div class="content">
<p>
<i class="info icon"></i>
<translate translate-context="Content/Library/Paragraph" :translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}">Supported extensions: %{ extensions }</translate>
<i class="info icon" />
<translate
translate-context="Content/Library/Paragraph"
:translate-params="{extensions: $store.state.ui.supportedExtensions.join(', ')}"
>
Supported extensions: %{ extensions }
</translate>
</p>
</div>
</div>
<file-upload-widget
ref="upload"
:class="['ui', 'icon', 'basic', 'button', 'channels', {hidden: step === 3}]"
:post-action="uploadUrl"
:multiple="true"
@ -121,21 +212,25 @@
:drop="true"
:extensions="$store.state.ui.supportedExtensions"
:value="files"
@input="updateFiles"
name="audio_file"
:thread="1"
@input="updateFiles"
@input-file="inputFile"
ref="upload">
>
<div>
<i class="upload icon"></i>&nbsp;
<translate translate-context="Content/Channels/Paragraph">Drag and drop your files here or open the browser to upload your files</translate>
<i class="upload icon" />&nbsp;
<translate translate-context="Content/Channels/Paragraph">
Drag and drop your files here or open the browser to upload your files
</translate>
</div>
<div class="ui very small divider"></div>
<div class="ui very small divider" />
<div>
<translate translate-context="*/*/*">Browse</translate>
<translate translate-context="*/*/*">
Browse
</translate>
</div>
</file-upload-widget>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
</template>
</template>
</form>
@ -146,31 +241,31 @@ import $ from 'jquery'
import LicenseSelect from '@/components/channels/LicenseSelect'
import AlbumSelect from '@/components/channels/AlbumSelect'
import FileUploadWidget from "@/components/library/FileUploadWidget";
import FileUploadWidget from '@/components/library/FileUploadWidget'
import UploadMetadataForm from '@/components/channels/UploadMetadataForm'
function setIfEmpty (obj, k, v) {
if (obj[k] != undefined) {
if (obj[k] !== undefined) {
return
}
obj[k] = v
}
export default {
props: {
channel: {type: Object, default: null, required: false},
},
components: {
AlbumSelect,
LicenseSelect,
FileUploadWidget,
UploadMetadataForm,
UploadMetadataForm
},
props: {
channel: { type: Object, default: null, required: false }
},
data () {
return {
availableChannels: {
results: [],
count: 0,
count: 0
},
audioMetadata: {},
uploadData: {},
@ -180,29 +275,22 @@ export default {
errors: [],
removed: [],
includeDraftUploads: null,
uploadUrl: this.$store.getters['instance/absoluteUrl']("/api/v1/uploads/"),
uploadUrl: this.$store.getters['instance/absoluteUrl']('/api/v1/uploads/'),
quotaStatus: null,
isLoadingStep1: true,
step: 1,
values: {
channel: (this.channel || {}).uuid,
license: null,
album: null,
album: null
},
selectedUploadId: null,
selectedUploadId: null
}
},
async created () {
this.isLoadingStep1 = true
let p1 = this.fetchChannels()
await p1
this.isLoadingStep1 = false
this.fetchQuota()
},
computed: {
labels () {
return {
editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit'),
editTitle: this.$pgettext('Content/*/Button.Label/Verb', 'Edit')
}
},
@ -210,7 +298,7 @@ export default {
return {
channel: this.values.channel,
import_status: 'draft',
import_metadata: {license: this.values.license, album: this.values.album || null}
import_metadata: { license: this.values.license, album: this.values.album || null }
}
},
remainingSpace () {
@ -220,18 +308,18 @@ export default {
return Math.max(0, this.quotaStatus.remaining - (this.uploadedSize / (1000 * 1000)))
},
selectedChannel () {
let self = this
const self = this
return this.availableChannels.results.filter((c) => {
return c.uuid === self.values.channel
})[0]
},
selectedUpload () {
let self = this
const self = this
if (!this.selectedUploadId) {
return null
}
let selected = this.uploadedFiles.filter((f) => {
return f.response && f.response.uuid == self.selectedUploadId
const selected = this.uploadedFiles.filter((f) => {
return f.response && f.response.uuid === self.selectedUploadId
})[0]
return {
...selected.response,
@ -239,27 +327,24 @@ export default {
}
},
uploadedFilesById () {
let data = {}
const data = {}
this.uploadedFiles.forEach((u) => {
data[u.response.uuid] = u
})
return data
},
uploadedFiles () {
let self = this
self.uploadData
self.audioMetadata
let files = this.files.map((f) => {
let data = {
const self = this
const files = this.files.map((f) => {
const data = {
...f,
_fileObj: f,
metadata: {}
}
let metadata = {}
if (f.response && f.response.uuid) {
let uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata
const uploadImportMetadata = self.uploadImportData[f.response.uuid] || self.uploadData[f.response.uuid].import_metadata
data.metadata = {
...uploadImportMetadata,
...uploadImportMetadata
}
data.removed = self.removed.indexOf(f.response.uuid) >= 0
}
@ -308,7 +393,7 @@ export default {
canSubmit: !this.activeFile && this.uploadedFiles.length > 0,
speed,
remaining,
quotaStatus: this.quotaStatus,
quotaStatus: this.quotaStatus
}
},
totalSize () {
@ -335,44 +420,92 @@ export default {
})[0]
}
},
watch: {
'availableChannels.results' () {
this.setupChannelsDropdown()
},
'values.channel': {
async handler (v) {
this.files = []
if (v) {
await this.fetchDraftUploads(v)
}
},
immediate: true
},
step: {
handler (value) {
this.$emit('step', value)
if (value === 2) {
this.selectedUploadId = null
}
},
immediate: true
},
async selectedUploadId (v, o) {
if (v) {
this.step = 3
} else {
this.step = 2
}
if (o) {
await this.patchUpload(o, { import_metadata: this.uploadImportData[o] })
}
},
summaryData: {
handler (v) {
this.$emit('status', v)
},
immediate: true
}
},
async created () {
this.isLoadingStep1 = true
const p1 = this.fetchChannels()
await p1
this.isLoadingStep1 = false
this.fetchQuota()
},
methods: {
async fetchChannels () {
let response = await axios.get('channels/', {params: {scope: 'me'}})
const response = await axios.get('channels/', { params: { scope: 'me' } })
this.availableChannels = response.data
},
async patchUpload (id, data) {
let response = await axios.patch(`uploads/${id}/`, data)
const response = await axios.patch(`uploads/${id}/`, data)
this.uploadData[id] = response.data
this.uploadImportData[id] = response.data.import_metadata
},
fetchQuota () {
let self = this
const self = this
axios.get('users/me/').then((response) => {
self.quotaStatus = response.data.quota_status
})
},
publish () {
let self = this
const self = this
self.isLoading = true
self.errors = []
let ids = this.uploadedFiles.map((f) => {
const ids = this.uploadedFiles.map((f) => {
return f.response.uuid
})
let payload = {
const payload = {
action: 'publish',
objects: ids,
objects: ids
}
return axios.post('uploads/action/', payload).then(
response => {
self.isLoading = false
self.$emit("published", {
self.$emit('published', {
uploads: self.uploadedFiles.map((u) => {
return {
...u.response,
import_status: 'pending',
import_status: 'pending'
}
}),
channel: self.selectedChannel})
channel: self.selectedChannel
})
},
error => {
self.errors = error.backendErrors
@ -380,32 +513,31 @@ export default {
)
},
setupChannelsDropdown () {
let self = this
const self = this
$(this.$el).find('#channel-dropdown').dropdown({
onChange (value, text, $choice) {
self.values.channel = value
},
values: this.availableChannels.results.map((c) => {
let d = {
const d = {
name: c.artist.name,
value: c.uuid,
selected: self.channel && self.channel.uuid === c.uuid,
selected: self.channel && self.channel.uuid === c.uuid
}
if (c.artist.cover && c.artist.cover.urls.medium_square_crop) {
let coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.urls.medium_square_crop)
const coverUrl = self.$store.getters['instance/absoluteUrl'](c.artist.cover.urls.medium_square_crop)
d.image = coverUrl
if (c.artist.content_category === 'podcast') {
d.imageClass = 'ui image'
} else {
d.imageClass = "ui avatar image"
d.imageClass = 'ui avatar image'
}
} else {
d.icon = "user"
d.icon = 'user'
if (c.artist.content_category === 'podcast') {
d.iconClass = "bordered icon"
d.iconClass = 'bordered icon'
} else {
d.iconClass = "circular icon"
d.iconClass = 'circular icon'
}
}
return d
@ -413,23 +545,23 @@ export default {
})
$(this.$el).find('#channel-dropdown').dropdown('hide')
},
inputFile(newFile, oldFile) {
inputFile (newFile, oldFile) {
if (!newFile) {
return
}
if (this.remainingSpace < newFile.size / (1000 * 1000)) {
newFile.error = 'denied'
} else {
this.$refs.upload.active = true;
this.$refs.upload.active = true
}
},
fetchAudioMetadata (uuid) {
let self = this
const self = this
self.audioMetadata[uuid] = null
axios.get(`uploads/${uuid}/audio-file-metadata/`).then((response) => {
self.setDynamic('audioMetadata', uuid, response.data)
let uploadedFile = self.uploadedFilesById[uuid]
if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, "") && response.data.title) {
const uploadedFile = self.uploadedFilesById[uuid]
if (uploadedFile._fileObj && uploadedFile.response.import_metadata.title === uploadedFile._fileObj.name.replace(/\.[^/.]+$/, '') && response.data.title) {
// replace existing title deduced from file by the one in audio file metadat, if any
self.uploadImportData[uuid].title = response.data.title
} else {
@ -439,17 +571,17 @@ export default {
setIfEmpty(self.uploadImportData[uuid], 'position', response.data.position)
setIfEmpty(self.uploadImportData[uuid], 'tags', response.data.tags)
setIfEmpty(self.uploadImportData[uuid], 'description', (response.data.description || {}).text)
self.patchUpload(uuid, {import_metadata: self.uploadImportData[uuid]})
self.patchUpload(uuid, { import_metadata: self.uploadImportData[uuid] })
})
},
setDynamic (objName, key, data) {
// cf https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats
let newData = {}
const newData = {}
newData[key] = data
this[objName] = Object.assign({}, this[objName], newData)
},
updateFiles (value) {
let self = this
const self = this
this.files = value
this.files.forEach((f) => {
if (f.response && f.response.uuid && self.audioMetadata[f.response.uuid] === undefined) {
@ -462,9 +594,9 @@ export default {
})
},
async fetchDraftUploads (channel) {
let self = this
const self = this
this.draftUploads = null
let response = await axios.get('uploads', {params: {import_status: 'draft', channel: channel}})
const response = await axios.get('uploads', { params: { import_status: 'draft', channel: channel } })
this.draftUploads = response.data.results
this.draftUploads.forEach((u) => {
self.uploadImportData[u.uuid] = u.import_metadata
@ -479,49 +611,8 @@ export default {
}
},
retry (file) {
this.$refs.upload.update(file, {error: '', progress: '0.00'})
this.$refs.upload.active = true;
}
},
watch: {
"availableChannels.results" () {
this.setupChannelsDropdown()
},
"values.channel": {
async handler (v) {
this.files = []
if (v) {
await this.fetchDraftUploads(v)
}
},
immediate: true,
},
step: {
handler (value) {
this.$emit('step', value)
if (value === 2) {
this.selectedUploadId = null
}
},
immediate: true,
},
async selectedUploadId (v, o) {
if (v) {
this.step = 3
} else {
this.step = 2
}
if (o) {
await this.patchUpload(o, {import_metadata: this.uploadImportData[o]})
}
},
summaryData: {
handler (v) {
this.$emit('status', v)
},
immediate: true,
this.$refs.upload.update(file, { error: '', progress: '0.00' })
this.$refs.upload.active = true
}
}
}

View File

@ -4,59 +4,79 @@
<label for="upload-title">
<translate translate-context="*/*/*/Noun">Title</translate>
</label>
<input type="text" v-model="newValues.title">
<input
v-model="newValues.title"
type="text"
>
</div>
<attachment-input
v-model="newValues.cover"
:required="false"
@delete="newValues.cover = null">
<translate translate-context="Content/Channel/*" slot="label">Track Picture</translate>
@delete="newValues.cover = null"
>
<translate
slot="label"
translate-context="Content/Channel/*"
>
Track Picture
</translate>
</attachment-input>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<div class="ui two fields">
<div class="ui field">
<label for="upload-tags">
<translate translate-context="*/*/*/Noun">Tags</translate>
</label>
<tags-selector
v-model="newValues.tags"
id="upload-tags"
:required="false"></tags-selector>
v-model="newValues.tags"
:required="false"
/>
</div>
<div class="ui field">
<label for="upload-position">
<translate translate-context="*/*/*/Short, Noun">Position</translate>
</label>
<input type="number" min="1" step="1" v-model="newValues.position">
<input
v-model="newValues.position"
type="number"
min="1"
step="1"
>
</div>
</div>
<div class="ui field">
<label for="upload-description">
<translate translate-context="*/*/*">Description</translate>
</label>
<content-form v-model="newValues.description" field-id="upload-description"></content-form>
<content-form
v-model="newValues.description"
field-id="upload-description"
/>
</div>
</div>
</template>
<script>
import axios from 'axios'
import TagsSelector from '@/components/library/TagsSelector'
import AttachmentInput from '@/components/common/AttachmentInput'
export default {
props: ['upload', 'values'],
components: {
TagsSelector,
AttachmentInput
},
props: {
upload: { type: Object, required: true },
values: { type: Object, required: true }
},
data () {
return {
newValues: {...this.values} || this.upload.import_metadata
newValues: { ...this.values } || this.upload.import_metadata
}
},
computed: {
isLoading () {
isLoading () {
return !!this.metadata
}
},
@ -66,7 +86,7 @@ export default {
this.$emit('values', v)
},
immediate: true
},
}
}
}
</script>

View File

@ -1,60 +1,142 @@
<template>
<modal class="small" @update:show="update" :show="$store.state.channels.showUploadModal">
<modal
class="small"
:show="$store.state.channels.showUploadModal"
@update:show="update"
>
<h4 class="header">
<translate key="1" v-if="step === 1" translate-context="Popup/Channels/Title/Verb">Publish audio</translate>
<translate key="2" v-else-if="step === 2" translate-context="Popup/Channels/Title">Files to upload</translate>
<translate key="3" v-else-if="step === 3" translate-context="Popup/Channels/Title">Upload details</translate>
<translate key="4" v-else-if="step === 4" translate-context="Popup/Channels/Title">Processing uploads</translate>
<translate
v-if="step === 1"
key="1"
translate-context="Popup/Channels/Title/Verb"
>
Publish audio
</translate>
<translate
v-else-if="step === 2"
key="2"
translate-context="Popup/Channels/Title"
>
Files to upload
</translate>
<translate
v-else-if="step === 3"
key="3"
translate-context="Popup/Channels/Title"
>
Upload details
</translate>
<translate
v-else-if="step === 4"
key="4"
translate-context="Popup/Channels/Title"
>
Processing uploads
</translate>
</h4>
<div class="scrolling content">
<channel-upload-form
ref="uploadForm"
:channel="$store.state.channels.uploadModalConfig.channel"
@step="step = $event"
@loading="isLoading = $event"
@published="$store.commit('channels/publish', $event)"
@status="statusData = $event"
@submittable="submittable = $event"
:channel="$store.state.channels.uploadModalConfig.channel"></channel-upload-form>
/>
</div>
<div class="actions">
<div class="left floated text left align">
<template v-if="statusData && step >= 2">
{{ statusInfo.join(' · ') }}
</template>
<div class="ui very small hidden divider"></div>
<div class="ui very small hidden divider" />
<template v-if="statusData && statusData.quotaStatus">
<translate translate-context="Content/Library/Paragraph">Remaining storage space:</translate>
<translate translate-context="Content/Library/Paragraph">
Remaining storage space:
</translate>
{{ (statusData.quotaStatus.remaining * 1000 * 1000) - statusData.uploadedSize | humanSize }}
</template>
</div>
<div class="ui hidden clearing divider mobile-only"></div>
<button class="ui basic cancel button" v-if="step === 1"><translate translate-context="*/*/Button.Label/Verb">Cancel</translate></button>
<button class="ui basic button" v-else-if="step < 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Previous step</translate></button>
<button class="ui basic button" v-else-if="step === 3" @click.stop.prevent="$refs.uploadForm.step -= 1"><translate translate-context="*/*/Button.Label/Verb">Update</translate></button>
<button v-if="step === 1" class="ui primary button" @click.stop.prevent="$refs.uploadForm.step += 1">
<translate translate-context="*/*/Button.Label">Next step</translate>
<div class="ui hidden clearing divider mobile-only" />
<button
v-if="step === 1"
class="ui basic cancel button"
>
<translate translate-context="*/*/Button.Label/Verb">
Cancel
</translate>
</button>
<div class="ui primary buttons" v-if="step === 2">
<button
v-else-if="step < 3"
class="ui basic button"
@click.stop.prevent="$refs.uploadForm.step -= 1"
>
<translate translate-context="*/*/Button.Label/Verb">
Previous step
</translate>
</button>
<button
v-else-if="step === 3"
class="ui basic button"
@click.stop.prevent="$refs.uploadForm.step -= 1"
>
<translate translate-context="*/*/Button.Label/Verb">
Update
</translate>
</button>
<button
v-if="step === 1"
class="ui primary button"
@click.stop.prevent="$refs.uploadForm.step += 1"
>
<translate translate-context="*/*/Button.Label">
Next step
</translate>
</button>
<div
v-if="step === 2"
class="ui primary buttons"
>
<button
:class="['ui', 'primary button', {loading: isLoading}]"
type="submit"
:disabled="!statusData || !statusData.canSubmit"
@click.prevent.stop="$refs.uploadForm.publish">
<translate translate-context="*/Channels/Button.Label">Publish</translate>
@click.prevent.stop="$refs.uploadForm.publish"
>
<translate translate-context="*/Channels/Button.Label">
Publish
</translate>
</button>
<button class="ui floating dropdown icon button" ref="dropdown" v-dropdown :disabled="!statusData || !statusData.canSubmit">
<i class="dropdown icon"></i>
<button
ref="dropdown"
v-dropdown
class="ui floating dropdown icon button"
:disabled="!statusData || !statusData.canSubmit"
>
<i class="dropdown icon" />
<div class="menu">
<div
role="button"
class="basic item"
@click="update(false)"
class="basic item">
<translate translate-context="Content/*/Button.Label/Verb">Finish later</translate>
>
<translate translate-context="Content/*/Button.Label/Verb">
Finish later
</translate>
</div>
</div>
</button>
</div>
<button class="ui basic cancel button" @click="update(false)" v-if="step === 4"><translate translate-context="*/*/Button.Label/Verb">Close</translate></button>
<button
v-if="step === 4"
class="ui basic cancel button"
@click="update(false)"
>
<translate translate-context="*/*/Button.Label/Verb">
Close
</translate>
</button>
</div>
</modal>
</template>
@ -62,7 +144,7 @@
<script>
import Modal from '@/components/semantic/Modal'
import ChannelUploadForm from '@/components/channels/UploadForm'
import {humanSize} from '@/filters'
import { humanSize } from '@/filters'
export default {
components: {
@ -74,14 +156,9 @@ export default {
step: 1,
isLoading: false,
submittable: true,
statusData: null,
statusData: null
}
},
methods: {
update (v) {
this.$store.commit('channels/showUploadModal', {show: v})
},
},
computed: {
labels () {
return {}
@ -90,14 +167,14 @@ export default {
if (!this.statusData) {
return []
}
let info = []
const info = []
if (this.statusData.totalSize) {
info.push(humanSize(this.statusData.totalSize))
}
if (this.statusData.totalFiles) {
let msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles)
const msg = this.$npgettext('*/*/*', '%{ count } file', '%{ count } files', this.statusData.totalFiles)
info.push(
this.$gettextInterpolate(msg, {count: this.statusData.totalFiles}),
this.$gettextInterpolate(msg, { count: this.statusData.totalFiles })
)
}
if (this.statusData.progress) {
@ -107,13 +184,17 @@ export default {
info.push(`${humanSize(this.statusData.speed)}/s`)
}
return info
}
},
watch: {
'$store.state.route.path' () {
this.$store.commit('channels/showUploadModal', {show: false})
},
this.$store.commit('channels/showUploadModal', { show: false })
}
},
methods: {
update (v) {
this.$store.commit('channels/showUploadModal', { show: v })
}
}
}
</script>

View File

@ -1,32 +1,35 @@
<template>
<span class="feedback" v-if="isLoading || isDone">
<span v-if="isLoading" :class="['ui', 'active', size, 'inline', 'loader']"></span>
<i v-if="isDone" :class="['success', size, 'check', 'icon']"></i>
<span
v-if="isLoading || isDone"
class="feedback"
>
<span
v-if="isLoading"
:class="['ui', 'active', size, 'inline', 'loader']"
/>
<i
v-if="isDone"
:class="['success', size, 'check', 'icon']"
/>
</span>
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
export default {
props: {
isLoading: {type: Boolean, required: true},
size: {type: String, default: 'small'},
isLoading: { type: Boolean, required: true },
size: { type: String, default: 'small' }
},
data () {
return {
timer: null,
isDone: false,
}
},
destroyed () {
if (this.timer) {
clearTimeout(this.timer)
isDone: false
}
},
watch: {
isLoading (v) {
let self = this
const self = this
if (v && this.timer) {
clearTimeout(this.timer)
}
@ -36,10 +39,14 @@ export default {
this.isDone = true
this.timer = setTimeout(() => {
self.isDone = false
}, (2000));
}, (2000))
}
}
},
destroyed () {
if (this.timer) {
clearTimeout(this.timer)
}
}
}
</script>

View File

@ -4,111 +4,188 @@
<thead>
<tr>
<th colspan="1000">
<div v-if="refreshable" class="right floated">
<div
v-if="refreshable"
class="right floated"
>
<span v-if="needsRefresh">
<translate translate-context="Content/*/Button.Help text.Paragraph">Content has been updated, click refresh to see up-to-date content</translate>
</span>
<button
@click="$emit('refresh')"
class="ui basic icon button"
:title="labels.refresh"
:aria-label="labels.refresh">
<i class="refresh icon"></i>
:aria-label="labels.refresh"
@click="$emit('refresh')"
>
<i class="refresh icon" />
</button>
</div>
<div class="ui small left floated form" v-if="actionUrl && actions.length > 0">
<div
v-if="actionUrl && actions.length > 0"
class="ui small left floated form"
>
<div class="ui inline fields">
<div class="field">
<label for="actions-select"><translate translate-context="Content/*/*/Noun">Actions</translate></label>
<select id="actions-select" class="ui dropdown" v-model="currentActionName">
<option v-for="action in actions" :value="action.name">
<select
id="actions-select"
v-model="currentActionName"
class="ui dropdown"
>
<option
v-for="(action, key) in actions"
:key="key"
:value="action.name"
>
{{ action.label }}
</option>
</select>
</div>
<div class="field">
<dangerous-button
v-if="selectAll || currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
v-if="selectAll || currentAction.isDangerous"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
:confirm-color="currentAction.confirmColor || 'success'"
@confirm="launchAction" :aria-label="labels.performAction">
<translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate>
:aria-label="labels.performAction"
@confirm="launchAction"
>
<translate translate-context="Content/*/Button.Label/Short, Verb">
Go
</translate>
<p slot="modal-header">
<translate translate-context="Modal/*/Title"
<translate
key="1"
translate-context="Modal/*/Title"
:translate-n="affectedObjectsCount"
:translate-params="{count: affectedObjectsCount, action: currentActionName}"
translate-plural="Do you want to launch %{ action } on %{ count } elements?">
translate-plural="Do you want to launch %{ action } on %{ count } elements?"
>
Do you want to launch %{ action } on %{ count } element?
</translate>
</p>
<p slot="modal-content">
<template v-if="currentAction.confirmationMessage">{{ currentAction.confirmationMessage }}</template>
<translate v-else translate-context="Modal/*/Paragraph">This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.</translate>
<template v-if="currentAction.confirmationMessage">
{{ currentAction.confirmationMessage }}
</template>
<translate
v-else
translate-context="Modal/*/Paragraph"
>
This may affect a lot of elements or have irreversible consequences, please double check this is really what you want.
</translate>
</p>
<div :aria-label="labels.performAction" slot="modal-confirm"><translate translate-context="Modal/*/Button.Label/Short, Verb">Launch</translate></div>
<div
slot="modal-confirm"
:aria-label="labels.performAction"
>
<translate translate-context="Modal/*/Button.Label/Short, Verb">
Launch
</translate>
</div>
</dangerous-button>
<button
v-else
@click="launchAction"
:disabled="checked.length === 0"
:aria-label="labels.performAction"
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
<translate translate-context="Content/*/Button.Label/Short, Verb">Go</translate></button>
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
@click="launchAction"
>
<translate translate-context="Content/*/Button.Label/Short, Verb">
Go
</translate>
</button>
</div>
<div class="count field">
<translate translate-context="Content/*/Paragraph"
tag="span"
<translate
v-if="selectAll"
key="1"
translate-context="Content/*/Paragraph"
tag="span"
:translate-n="objectsData.count"
:translate-params="{count: objectsData.count, total: objectsData.count}"
translate-plural="All %{ count } elements selected">
translate-plural="All %{ count } elements selected"
>
All %{ count } element selected
</translate>
<translate translate-context="Content/*/Paragraph"
tag="span"
<translate
v-else
key="2"
translate-context="Content/*/Paragraph"
tag="span"
:translate-n="checked.length"
:translate-params="{count: checked.length, total: objectsData.count}"
translate-plural="%{ count } on %{ total } selected">
translate-plural="%{ count } on %{ total } selected"
>
%{ count } on %{ total } selected
</translate>
<template v-if="currentAction.allowAll && checkable.length > 0 && checkable.length === checked.length">
<a @click.prevent="selectAll = true" v-if="!selectAll" href="">
<translate translate-context="Content/*/Link/Verb"
<a
v-if="!selectAll"
href=""
@click.prevent="selectAll = true"
>
<translate
key="3"
translate-context="Content/*/Link/Verb"
:translate-n="objectsData.count"
:translate-params="{total: objectsData.count}"
translate-plural="Select all %{ total } elements">
translate-plural="Select all %{ total } elements"
>
Select one element
</translate>
</a>
<a @click.prevent="selectAll = false" v-else href="">
<translate translate-context="Content/*/Link/Verb" key="4">Select only current page</translate>
<a
v-else
href=""
@click.prevent="selectAll = false"
>
<translate
key="4"
translate-context="Content/*/Link/Verb"
>Select only current page</translate>
</a>
</template>
</div>
</div>
<div v-if="actionErrors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message/Header">Error while applying action</translate></h4>
<div
v-if="actionErrors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message/Header">
Error while applying action
</translate>
</h4>
<ul class="list">
<li v-for="error in actionErrors">{{ error }}</li>
<li
v-for="(error, key) in actionErrors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div v-if="actionResult" class="ui positive message">
<div
v-if="actionResult"
class="ui positive message"
>
<p>
<translate translate-context="Content/*/Paragraph"
<translate
translate-context="Content/*/Paragraph"
:translate-n="actionResult.updated"
:translate-params="{count: actionResult.updated, action: actionResult.action}"
translate-plural="Action %{ action } was launched successfully on %{ count } elements">
translate-plural="Action %{ action } was launched successfully on %{ count } elements"
>
Action %{ action } was launched successfully on %{ count } element
</translate>
</p>
<slot name="action-success-footer" :result="actionResult">
</slot>
<slot
name="action-success-footer"
:result="actionResult"
/>
</div>
</div>
</th>
@ -118,26 +195,37 @@
<div class="ui checkbox">
<input
type="checkbox"
@change="toggleCheckAll"
:aria-label="labels.selectAllItems"
:disabled="checkable.length === 0"
:checked="checkable.length > 0 && checked.length === checkable.length">
:checked="checkable.length > 0 && checked.length === checkable.length"
@change="toggleCheckAll"
>
</div>
</th>
<slot name="header-cells"></slot>
<slot name="header-cells" />
</tr>
</thead>
<tbody v-if="objectsData.count > 0">
<tr v-for="(obj, index) in objects">
<td v-if="actions.length > 0" class="collapsing">
<tr
v-for="(obj, index) in objects"
:key="index"
>
<td
v-if="actions.length > 0"
class="collapsing"
>
<input
type="checkbox"
:aria-label="labels.selectItem"
:disabled="checkable.indexOf(getId(obj)) === -1"
:checked="checked.indexOf(getId(obj)) > -1"
@click="toggleCheck($event, getId(obj), index)"
:checked="checked.indexOf(getId(obj)) > -1">
>
</td>
<slot name="row-cells" :obj="obj"></slot>
<slot
name="row-cells"
:obj="obj"
/>
</tr>
</tbody>
</table>
@ -147,19 +235,19 @@
import axios from 'axios'
export default {
props: {
actionUrl: {type: String, required: false, default: null},
idField: {type: String, required: false, default: 'id'},
refreshable: {type: Boolean, required: false, default: false},
needsRefresh: {type: Boolean, required: false, default: false},
objectsData: {type: Object, required: true},
actions: {type: Array, required: true, default: () => { return [] }},
filters: {type: Object, required: false, default: () => { return {} }},
customObjects: {type: Array, required: false, default: () => { return [] }},
},
components: {},
props: {
actionUrl: { type: String, required: false, default: null },
idField: { type: String, required: false, default: 'id' },
refreshable: { type: Boolean, required: false, default: false },
needsRefresh: { type: Boolean, required: false, default: false },
objectsData: { type: Object, required: true },
actions: { type: Array, required: true, default: () => { return [] } },
filters: { type: Object, required: false, default: () => { return {} } },
customObjects: { type: Array, required: false, default: () => { return [] } }
},
data () {
let d = {
const d = {
checked: [],
actionLoading: false,
actionResult: null,
@ -173,86 +261,20 @@ export default {
}
return d
},
methods: {
toggleCheckAll () {
this.lastCheckedIndex = -1
if (this.checked.length === this.checkable.length) {
// we uncheck
this.checked = []
} else {
this.checked = this.checkable.map(i => { return i })
}
},
toggleCheck (event, id, index) {
let self = this
let affectedIds = [id]
let newValue = null
if (this.checked.indexOf(id) > -1) {
// we uncheck
this.selectAll = false
newValue = false
} else {
newValue = true
}
if (event.shiftKey && this.lastCheckedIndex > -1) {
// we also add inbetween ids to the list of affected ids
let idxs = [index, this.lastCheckedIndex]
idxs.sort((a, b) => a - b)
let objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
}
affectedIds.forEach((i) => {
let checked = self.checked.indexOf(i) > -1
if (newValue && !checked && self.checkable.indexOf(i) > -1) {
return self.checked.push(i)
}
if (!newValue && checked) {
self.checked.splice(self.checked.indexOf(i), 1)
}
})
this.lastCheckedIndex = index
},
launchAction () {
let self = this
self.actionLoading = true
self.result = null
self.actionErrors = []
let payload = {
action: this.currentActionName,
filters: this.filters
}
if (this.selectAll) {
payload.objects = 'all'
} else {
payload.objects = this.checked
}
axios.post(this.actionUrl, payload).then((response) => {
self.actionResult = response.data
self.actionLoading = false
self.$emit('action-launched', response.data)
}, error => {
self.actionLoading = false
self.actionErrors = error.backendErrors
})
},
getId (obj) {
return obj[this.idField]
}
},
computed: {
currentAction () {
let self = this
const self = this
return this.actions.filter((a) => {
return a.name === self.currentActionName
})[0]
},
checkable () {
let self = this
const self = this
if (!this.currentAction) {
return []
}
let objs = this.objectsData.results
let filter = this.currentAction.filterCheckable
const filter = this.currentAction.filterCheckable
if (filter) {
objs = objs.filter((o) => {
return filter(o)
@ -261,9 +283,9 @@ export default {
return objs.map((o) => { return self.getId(o) })
},
objects () {
let self = this
const self = this
return this.objectsData.results.map((o) => {
let custom = self.customObjects.filter((co) => {
const custom = self.customObjects.filter((co) => {
return self.getId(co) === self.getId(o)
})[0]
if (custom) {
@ -298,11 +320,77 @@ export default {
currentActionName () {
// we update checked status as some actions have specific filters
// on what is checkable or not
let self = this
const self = this
this.checked = this.checked.filter(r => {
return self.checkable.indexOf(r) > -1
})
}
},
methods: {
toggleCheckAll () {
this.lastCheckedIndex = -1
if (this.checked.length === this.checkable.length) {
// we uncheck
this.checked = []
} else {
this.checked = this.checkable.map(i => { return i })
}
},
toggleCheck (event, id, index) {
const self = this
let affectedIds = [id]
let newValue = null
if (this.checked.indexOf(id) > -1) {
// we uncheck
this.selectAll = false
newValue = false
} else {
newValue = true
}
if (event.shiftKey && this.lastCheckedIndex > -1) {
// we also add inbetween ids to the list of affected ids
const idxs = [index, this.lastCheckedIndex]
idxs.sort((a, b) => a - b)
const objs = this.objectsData.results.slice(idxs[0], idxs[1] + 1)
affectedIds = affectedIds.concat(objs.map((o) => { return o.id }))
}
affectedIds.forEach((i) => {
const checked = self.checked.indexOf(i) > -1
if (newValue && !checked && self.checkable.indexOf(i) > -1) {
return self.checked.push(i)
}
if (!newValue && checked) {
self.checked.splice(self.checked.indexOf(i), 1)
}
})
this.lastCheckedIndex = index
},
launchAction () {
const self = this
self.actionLoading = true
self.result = null
self.actionErrors = []
const payload = {
action: this.currentActionName,
filters: this.filters
}
if (this.selectAll) {
payload.objects = 'all'
} else {
payload.objects = this.checked
}
axios.post(this.actionUrl, payload).then((response) => {
self.actionResult = response.data
self.actionLoading = false
self.$emit('action-launched', response.data)
}, error => {
self.actionLoading = false
self.actionErrors = error.backendErrors
})
},
getId (obj) {
return obj[this.idField]
}
}
}
</script>

View File

@ -1,13 +1,22 @@
<template>
<img alt="" v-if="actor.icon && actor.icon.urls.original" :src="actor.icon.urls.medium_square_crop" class="ui avatar circular image" />
<span v-else :style="defaultAvatarStyle" class="ui avatar circular label">{{ actor.preferred_username[0]}}</span>
<img
v-if="actor.icon && actor.icon.urls.original"
alt=""
:src="actor.icon.urls.medium_square_crop"
class="ui avatar circular image"
>
<span
v-else
:style="defaultAvatarStyle"
class="ui avatar circular label"
>{{ actor.preferred_username[0] }}</span>
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
import { hashCode, intToRGB } from '@/utils/color'
export default {
props: ['actor'],
props: { actor: { type: Object, required: true } },
computed: {
actorColor () {
return intToRGB(hashCode(this.actor.full_username))

View File

@ -1,29 +1,33 @@
<template>
<router-link :to="url" :title="actor.full_username">
<template v-if="avatar"><actor-avatar :actor="actor" /><span>&nbsp;</span></template><slot>{{ repr | truncate(truncateLength) }}</slot>
<router-link
:to="url"
:title="actor.full_username"
>
<template v-if="avatar">
<actor-avatar :actor="actor" /><span>&nbsp;</span>
</template><slot>{{ repr | truncate(truncateLength) }}</slot>
</router-link>
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
export default {
props: {
actor: {type: Object},
avatar: {type: Boolean, default: true},
admin: {type: Boolean, default: false},
displayName: {type: Boolean, default: false},
truncateLength: {type: Number, default: 30},
actor: { type: Object, required: true },
avatar: { type: Boolean, default: true },
admin: { type: Boolean, default: false },
displayName: { type: Boolean, default: false },
truncateLength: { type: Number, default: 30 }
},
computed: {
url () {
if (this.admin) {
return {name: 'manage.moderation.accounts.detail', params: {id: this.actor.full_username}}
return { name: 'manage.moderation.accounts.detail', params: { id: this.actor.full_username } }
}
if (this.actor.is_local) {
return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
return { name: 'profile.overview', params: { username: this.actor.preferred_username } }
} else {
return {name: 'profile.full.overview', params: {username: this.actor.preferred_username, domain: this.actor.domain}}
return { name: 'profile.full.overview', params: { username: this.actor.preferred_username, domain: this.actor.domain } }
}
},
repr () {

View File

@ -1,6 +1,9 @@
<template>
<button @click="ajaxCall" :class="['ui', {loading: isLoading}, 'button']">
<slot></slot>
<button
:class="['ui', {loading: isLoading}, 'button']"
@click="ajaxCall"
>
<slot />
</button>
</template>
<script>
@ -8,17 +11,17 @@ import axios from 'axios'
export default {
props: {
url: {type: String, required: true},
method: {type: String, required: true},
url: { type: String, required: true },
method: { type: String, required: true }
},
data () {
return {
isLoading: false,
isLoading: false
}
},
methods: {
ajaxCall () {
var self = this
const self = this
this.isLoading = true
axios[this.method](this.url).then(response => {
self.$emit('action-done', response.data)

View File

@ -1,36 +1,84 @@
<template>
<div class="ui form">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></h4>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Your attachment cannot be saved
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div class="ui field">
<span id="avatarLabel">
<slot name="label"></slot>
<slot name="label" />
</span>
<div class="ui stackable grid row">
<div class="three wide column">
<img alt="" :class="['ui', imageClass, 'image']" v-if="value && value === initialValue" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)" />
<img alt="" :class="['ui', imageClass, 'image']" v-else-if="attachment" :src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)" />
<div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div>
<img
v-if="value && value === initialValue"
alt=""
:class="['ui', imageClass, 'image']"
:src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${value}/proxy?next=medium_square_crop`)"
>
<img
v-else-if="attachment"
alt=""
:class="['ui', imageClass, 'image']"
:src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${attachment.uuid}/proxy?next=medium_square_crop`)"
>
<div
v-else
:class="['ui', imageClass, 'static', 'large placeholder image']"
/>
</div>
<div class="eleven wide column">
<div class="file-input">
<label :for="attachmentId">
<translate translate-context="*/*/*">Upload New Picture</translate>
</label>
<input class="ui input" ref="attachment" type="file" :id="attachmentId" accept="image/x-png,image/jpeg" @change="submit" />
<input
:id="attachmentId"
ref="attachment"
class="ui input"
type="file"
accept="image/x-png,image/jpeg"
@change="submit"
>
</div>
<div class="ui very small hidden divider"></div>
<p><translate translate-context="Content/*/Paragraph">PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB.</translate></p>
<button class="ui basic tiny button" v-if="value" @click.stop.prevent="remove(value)">
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate>
<div class="ui very small hidden divider" />
<p>
<translate translate-context="Content/*/Paragraph">
PNG or JPG. Dimensions should be between 1400x1400px and 3000x3000px. Maximum file size allowed is 5MB.
</translate>
</p>
<button
v-if="value"
class="ui basic tiny button"
@click.stop.prevent="remove(value)"
>
<translate translate-context="Content/Radio/Button.Label/Verb">
Remove
</translate>
</button>
<div v-if="isLoading" class="ui active inverted dimmer">
<div
v-if="isLoading"
class="ui active inverted dimmer"
>
<div class="ui indeterminate text loader">
<translate translate-context="Content/*/*/Noun">Uploading file</translate>
<translate translate-context="Content/*/*/Noun">
Uploading file
</translate>
</div>
</div>
</div>
@ -43,8 +91,8 @@ import axios from 'axios'
export default {
props: {
value: {},
imageClass: {default: '', required: false}
value: { type: String, required: true },
imageClass: { type: String, default: '', required: false }
},
data () {
return {
@ -52,21 +100,29 @@ export default {
isLoading: false,
errors: [],
initialValue: this.value,
attachmentId: Math.random().toString(36).substring(7),
attachmentId: Math.random().toString(36).substring(7)
}
},
watch: {
value (v) {
if (this.attachment && v === this.initialValue) {
// we had a reset to initial value
this.remove(this.attachment.uuid)
}
}
},
methods: {
submit() {
submit () {
this.isLoading = true
this.errors = []
let self = this
const self = this
this.file = this.$refs.attachment.files[0]
let formData = new FormData()
formData.append("file", this.file)
const formData = new FormData()
formData.append('file', this.file)
axios
.post(`attachments/`, formData, {
.post('attachments/', formData, {
headers: {
"Content-Type": "multipart/form-data"
'Content-Type': 'multipart/form-data'
}
})
.then(
@ -81,10 +137,10 @@ export default {
}
)
},
remove(uuid) {
remove (uuid) {
this.isLoading = true
this.errors = []
let self = this
const self = this
axios.delete(`attachments/${uuid}/`)
.then(
response => {
@ -97,14 +153,6 @@ export default {
self.errors = error.backendErrors
}
)
},
},
watch: {
value (v) {
if (this.attachment && v === this.initialValue) {
// we had a reset to initial value
this.remove(this.attachment.uuid)
}
}
}
}

View File

@ -1,15 +1,27 @@
<template>
<a role="button" class="collapse link" @click.prevent="$emit('input', !value)">
<translate v-if="isCollapsed" key="1" translate-context="*/*/Button,Label">Expand</translate>
<translate v-else key="2" translate-context="*/*/Button,Label">Collapse</translate>
<i :class="[{down: !isCollapsed}, {right: isCollapsed}, 'angle', 'icon']"></i>
<a
role="button"
class="collapse link"
@click.prevent="$emit('input', !value)"
>
<translate
v-if="isCollapsed"
key="1"
translate-context="*/*/Button,Label"
>Expand</translate>
<translate
v-else
key="2"
translate-context="*/*/Button,Label"
>Collapse</translate>
<i :class="[{down: !isCollapsed}, {right: isCollapsed}, 'angle', 'icon']" />
</a>
</template>
<script>
export default {
props: {
value: {type: Boolean, required: true},
value: { type: Boolean, required: true }
},
computed: {
isCollapsed () {

View File

@ -2,48 +2,71 @@
<div class="content-form ui segments">
<div class="ui segment">
<div class="ui tiny secondary pointing menu">
<button @click.prevent="isPreviewing = false" :class="[{active: !isPreviewing}, 'item']">
<translate translate-context="*/Form/Menu.item">Write</translate>
<button
:class="[{active: !isPreviewing}, 'item']"
@click.prevent="isPreviewing = false"
>
<translate translate-context="*/Form/Menu.item">
Write
</translate>
</button>
<button @click.prevent="isPreviewing = true" :class="[{active: isPreviewing}, 'item']">
<translate translate-context="*/Form/Menu.item">Preview</translate>
<button
:class="[{active: isPreviewing}, 'item']"
@click.prevent="isPreviewing = true"
>
<translate translate-context="*/Form/Menu.item">
Preview
</translate>
</button>
</div>
<template v-if="isPreviewing" >
<div class="ui placeholder" v-if="isLoadingPreview">
<template v-if="isPreviewing">
<div
v-if="isLoadingPreview"
class="ui placeholder"
>
<div class="paragraph">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
<div class="line" />
<div class="line" />
<div class="line" />
<div class="line" />
</div>
</div>
<p v-else-if="preview === null">
<translate translate-context="*/Form/Paragraph">Nothing to preview.</translate>
<translate translate-context="*/Form/Paragraph">
Nothing to preview.
</translate>
</p>
<div v-html="preview" v-else></div>
<div
v-else
v-html="preview"
/>
</template>
<template v-else>
<div class="ui transparent input">
<textarea
ref="textarea"
:name="fieldId"
:id="fieldId"
:rows="rows"
ref="textarea"
v-model="newValue"
:name="fieldId"
:rows="rows"
:required="required"
:placeholder="placeholder || labels.placeholder"></textarea>
:placeholder="placeholder || labels.placeholder"
/>
</div>
<div class="ui very small hidden divider"></div>
<div class="ui very small hidden divider" />
</template>
</div>
<div class="ui bottom attached segment">
<span :class="['right', 'floated', {'ui danger text': remainingChars < 0}]" v-if="charLimit">
<span
v-if="charLimit"
:class="['right', 'floated', {'ui danger text': remainingChars < 0}]"
>
{{ remainingChars }}
</span>
<p>
<translate translate-context="*/Form/Paragraph">Markdown syntax is supported.</translate>
<translate translate-context="*/Form/Paragraph">
Markdown syntax is supported.
</translate>
</p>
</div>
</div>
@ -54,50 +77,31 @@ import axios from 'axios'
export default {
props: {
value: {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},
value: { 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.value,
isLoadingPreview: false,
}
},
mounted () {
if (this.autofocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
},
methods: {
async loadPreview () {
this.isLoadingPreview = true
try {
let response = await axios.post('text-preview/', {text: this.newValue, permissive: this.permissive})
this.preview = response.data.rendered
} catch {
}
this.isLoadingPreview = false
isLoadingPreview: false
}
},
computed: {
labels () {
return {
placeholder: this.$pgettext("*/Form/Placeholder", "Write a few words here…")
placeholder: this.$pgettext('*/Form/Placeholder', 'Write a few words here…')
}
},
remainingChars () {
return this.charLimit - (this.value || "").length
return this.charLimit - (this.value || '').length
}
},
watch: {
@ -113,7 +117,7 @@ export default {
await this.loadPreview()
}
},
immediate: true,
immediate: true
},
async isPreviewing (v) {
if (v && !!this.value && this.preview === null && !this.isLoadingPreview) {
@ -125,6 +129,25 @@ export default {
})
}
}
},
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 {
}
this.isLoadingPreview = false
}
}
}
</script>

View File

@ -1,21 +1,38 @@
<template>
<div class="ui fluid action input component-copy-input">
<p class="message" v-if="copied">
<translate translate-context="Content/*/Paragraph">Text copied to clipboard!</translate>
<p
v-if="copied"
class="message"
>
<translate translate-context="Content/*/Paragraph">
Text copied to clipboard!
</translate>
</p>
<input :id="id" :name="id" ref="input" :value="value" type="text" readonly>
<button @click="copy" :class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']">
<i class="copy icon"></i>
<translate translate-context="*/*/Button.Label/Short, Verb">Copy</translate>
<input
:id="id"
ref="input"
:name="id"
:value="value"
type="text"
readonly
>
<button
:class="['ui', buttonClasses, 'right', 'labeled', 'icon', 'button']"
@click="copy"
>
<i class="copy icon" />
<translate translate-context="*/*/Button.Label/Short, Verb">
Copy
</translate>
</button>
</div>
</template>
<script>
export default {
props: {
value: {type: String},
buttonClasses: {type: String, default: 'accent'},
id: {type: String, default: 'copy-input'},
value: { type: String, required: true },
buttonClasses: { type: String, default: 'accent' },
id: { type: String, default: 'copy-input' }
},
data () {
return {
@ -29,8 +46,8 @@ export default {
clearTimeout(this.timeout)
}
this.$refs.input.select()
document.execCommand("Copy")
let self = this
document.execCommand('Copy')
const self = this
self.copied = true
this.timeout = setTimeout(() => {
self.copied = false

View File

@ -1,44 +1,59 @@
<template>
<button @click="showModal = true" :class="[{disabled: disabled}]" :disabled="disabled">
<slot></slot>
<button
:class="[{disabled: disabled}]"
:disabled="disabled"
@click="showModal = true"
>
<slot />
<modal class="small" :show.sync="showModal">
<modal
class="small"
:show.sync="showModal"
>
<h4 class="header">
<slot name="modal-header">
<translate translate-context="Modal/*/Title">Do you want to confirm this action?</translate>
<translate translate-context="Modal/*/Title">
Do you want to confirm this action?
</translate>
</slot>
</h4>
<div class="scrolling content">
<div class="description">
<slot name="modal-content"></slot>
<slot name="modal-content" />
</div>
</div>
<div class="actions">
<button class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
<translate translate-context="*/*/Button.Label/Verb">
Cancel
</translate>
</button>
<button :class="['ui', 'confirm', confirmButtonColor, 'button']" @click="confirm">
<button
:class="['ui', 'confirm', confirmButtonColor, 'button']"
@click="confirm"
>
<slot name="modal-confirm">
<translate translate-context="Modal/*/Button.Label/Short, Verb">Confirm</translate>
<translate translate-context="Modal/*/Button.Label/Short, Verb">
Confirm
</translate>
</slot>
</button>
</div>
</modal>
</button>
</template>
<script>
import Modal from '@/components/semantic/Modal'
export default {
props: {
action: {type: Function, required: false},
disabled: {type: Boolean, default: false},
confirmColor: {type: String, default: "danger", required: false}
},
components: {
Modal
},
props: {
action: { type: Function, required: false, default: () => {} },
disabled: { type: Boolean, default: false },
confirmColor: { type: String, default: 'danger', required: false }
},
data () {
return {
showModal: false

View File

@ -1,18 +1,22 @@
<template>
<span>
<translate translate-context="Content/*/Paragraph"
<translate
v-if="durationData.hours > 0"
:translate-params="{minutes: durationData.minutes, hours: durationData.hours}">%{ hours } h %{ minutes } min</translate>
<translate translate-context="Content/*/Paragraph"
translate-context="Content/*/Paragraph"
:translate-params="{minutes: durationData.minutes, hours: durationData.hours}"
>%{ hours } h %{ minutes } min</translate>
<translate
v-else
:translate-params="{minutes: durationData.minutes}">%{ minutes } min</translate>
translate-context="Content/*/Paragraph"
:translate-params="{minutes: durationData.minutes}"
>%{ minutes } min</translate>
</span>
</template>
<script>
import {secondsToObject} from '@/filters'
import { secondsToObject } from '@/filters'
export default {
props: ['seconds'],
props: { seconds: { type: Number, required: true } },
computed: {
durationData () {
return secondsToObject(this.seconds)

View File

@ -3,8 +3,7 @@
<h4 class="ui header">
<div class="content">
<slot name="title">
<i class="search icon"></i>
<i class="search icon" />
<translate translate-context="Content/*/Paragraph">
No results were found.
</translate>
@ -12,8 +11,12 @@
</div>
</h4>
<div class="inline center aligned text">
<slot></slot>
<button v-if="refresh" class="ui button" @click="$emit('refresh')">
<slot />
<button
v-if="refresh"
class="ui button"
@click="$emit('refresh')"
>
<translate translate-context="Content/*/Button.Label/Short, Verb">
Refresh
</translate>
@ -24,7 +27,7 @@
<script>
export default {
props: {
refresh: {type: Boolean, default: false}
refresh: { type: Boolean, default: false }
}
}
</script>

View File

@ -3,10 +3,22 @@
<div :class="['expandable-content', {expandable: truncated.length < content.length}, {expanded: isExpanded}]">
<slot>{{ content }}</slot>
</div>
<a v-if="truncated.length < content.length" role="button" @click.prevent="isExpanded = !isExpanded">
<a
v-if="truncated.length < content.length"
role="button"
@click.prevent="isExpanded = !isExpanded"
>
<br>
<translate v-if="isExpanded" key="1" translate-context="*/*/Button,Label">Show less</translate>
<translate v-else key="2" translate-context="*/*/Button,Label">Show more</translate>
<translate
v-if="isExpanded"
key="1"
translate-context="*/*/Button,Label"
>Show less</translate>
<translate
v-else
key="2"
translate-context="*/*/Button,Label"
>Show more</translate>
</a>
</div>
</template>
@ -15,12 +27,12 @@
export default {
props: {
content: {type: String, required: true},
length: {type: Number, default: 150, required: false},
content: { type: String, required: true },
length: { type: Number, default: 150, required: false }
},
data () {
return {
isExpanded: false,
isExpanded: false
}
},
computed: {

View File

@ -1,15 +1,21 @@
<template>
<time :datetime="date" :title="date | moment">
<i v-if="icon" class="outline clock icon"></i>
<time
:datetime="date"
:title="date | moment"
>
<i
v-if="icon"
class="outline clock icon"
/>
{{ realDate | ago($store.state.ui.momentLocale) }}
</time>
</template>
<script>
import {mapState} from 'vuex'
import { mapState } from 'vuex'
export default {
props: {
date: {required: true},
icon: {type: Boolean, required: false, default: false},
date: { type: String, required: true },
icon: { type: Boolean, required: false, default: false }
},
computed: {
...mapState({

View File

@ -1,13 +1,12 @@
<template>
<time :datetime="`${duration}s`">
{{ duration | duration}}
{{ duration | duration }}
</time>
</template>
<script>
export default {
props: {
duration: {required: true},
},
duration: { type: Object, required: true }
}
}
</script>

View File

@ -1,13 +1,34 @@
<template>
<form class="ui inline form" @submit.stop.prevent="$emit('search', value)">
<form
class="ui inline form"
@submit.stop.prevent="$emit('search', value)"
>
<div :class="['ui', 'action', {icon: isClearable}, 'input']">
<label for="search-query" class="hidden">
<label
for="search-query"
class="hidden"
>
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
</label>
<input id="search-query" name="search-query" type="text" :placeholder="placeholder || labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
<i v-if="isClearable" class="x link icon" :title="labels.clear" @click.stop.prevent="$emit('input', ''); $emit('search', value)"></i>
<button type="submit" class="ui icon basic button">
<i class="search icon"></i>
<input
id="search-query"
name="search-query"
type="text"
:placeholder="placeholder || labels.searchPlaceholder"
:value="value"
@input="$emit('input', $event.target.value)"
>
<i
v-if="isClearable"
class="x link icon"
:title="labels.clear"
@click.stop.prevent="$emit('input', ''); $emit('search', value)"
/>
<button
type="submit"
class="ui icon basic button"
>
<i class="search icon" />
</button>
</div>
</form>
@ -15,14 +36,14 @@
<script>
export default {
props: {
value: {type: String, required: true},
placeholder: {type: String, required: false},
value: { type: String, required: true },
placeholder: { type: String, required: false, default: '' }
},
computed: {
labels () {
return {
searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search…'),
clear: this.$pgettext("Content/Library/Button.Label", 'Clear'),
clear: this.$pgettext('Content/Library/Button.Label', 'Clear')
}
},
isClearable () {

View File

@ -1,64 +1,81 @@
<template>
<modal :show.sync="show">
<h4 class="header">{{ labels.header }}</h4>
<div v-if="cover" class="image content">
<div class="ui medium image">
<img :src="cover.urls.medium_square_crop">
</div>
<div class="description">
<div class="ui header">
{{ labels.description }}
</div>
<p>
{{ message }}
</p>
</div>
<modal :show.sync="show">
<h4 class="header">
{{ labels.header }}
</h4>
<div
v-if="cover"
class="image content"
>
<div class="ui medium image">
<img :src="cover.urls.medium_square_crop">
</div>
<div v-else class="content">
<div class="ui centered header">
<div class="description">
<div class="ui header">
{{ labels.description }}
</div>
<p style="text-align: center;">
<p>
{{ message }}
</p>
</div>
<div class="actions">
<router-link :to="{path: '/login', query: { next: nextRoute }}" class="ui labeled icon button"><i class="key icon"></i>
{{ labels.login }}
</router-link>
<router-link v-if="$store.state.instance.settings.users.registration_enabled.value" :to="{path: '/signup'}" class="ui labeled icon button"><i class="user icon"></i>
{{ labels.signup }}
</router-link>
</div>
<div
v-else
class="content"
>
<div class="ui centered header">
{{ labels.description }}
</div>
</modal>
<p style="text-align: center;">
{{ message }}
</p>
</div>
<div class="actions">
<router-link
:to="{path: '/login', query: { next: nextRoute }}"
class="ui labeled icon button"
>
<i class="key icon" />
{{ labels.login }}
</router-link>
<router-link
v-if="$store.state.instance.settings.users.registration_enabled.value"
:to="{path: '/signup'}"
class="ui labeled icon button"
>
<i class="user icon" />
{{ labels.signup }}
</router-link>
</div>
</modal>
</template>
<script>
import Modal from '@/components/semantic/Modal'
export default {
props: {
nextRoute: {type: String},
message: {type: String},
cover: {type: Object},
},
components: {
Modal,
Modal
},
data() {
props: {
nextRoute: { type: String, required: true },
message: { type: String, required: true },
cover: { type: Object, required: true }
},
data () {
return {
show: false,
show: false
}
},
computed: {
labels() {
labels () {
return {
header: this.$pgettext('Popup/Title/Noun', "Unauthenticated"),
login: this.$pgettext('*/*/Button.Label/Verb', "Log in"),
signup: this.$pgettext('*/*/Button.Label/Verb', "Sign up"),
description: this.$pgettext('Popup/*/Paragraph', "You don't have access!"),
header: this.$pgettext('Popup/Title/Noun', 'Unauthenticated'),
login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'),
signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'),
description: this.$pgettext('Popup/*/Paragraph', "You don't have access!")
}
},
}
}
}

View File

@ -1,27 +1,27 @@
<template>
<div></div>
<div />
</template>
<script>
import $ from 'jquery'
export default {
props: ['message'],
props: { message: { type: Object, required: true } },
mounted () {
let self = this
let params = {
context: "#app",
const self = this
const params = {
context: '#app',
message: this.message.content,
showProgress: 'top',
position: "bottom right",
position: 'bottom right',
progressUp: true,
onRemove () {
self.$store.commit("ui/removeMessage", self.message.key)
self.$store.commit('ui/removeMessage', self.message.key)
},
...this.message,
...this.message
}
$("body").toast(params)
$('body').toast(params)
$(".ui.toast.visible").last().attr('role', 'alert')
$('.ui.toast.visible').last().attr('role', 'alert')
}
}
</script>

View File

@ -1,79 +1,113 @@
<template>
<div>
<template v-if="content && !isUpdating">
<div v-html="html"></div>
<div v-html="html" />
<template v-if="isTruncated">
<div class="ui small hidden divider"></div>
<a href="" @click.stop.prevent="showMore = true" v-if="showMore === false">
<div class="ui small hidden divider" />
<a
v-if="showMore === false"
href=""
@click.stop.prevent="showMore = true"
>
<translate translate-context="*/*/Button,Label">Show more</translate>
</a>
<a href="" @click.stop.prevent="showMore = false" v-else="showMore === true">
<a
v-else
href=""
@click.stop.prevent="showMore = false"
>
<translate translate-context="*/*/Button,Label">Show less</translate>
</a>
</template>
</template>
<p v-else-if="!isUpdating">
<translate translate-context="*/*/Placeholder">No description available</translate>
<translate translate-context="*/*/Placeholder">
No description available
</translate>
</p>
<template v-if="!isUpdating && canUpdate && updateUrl">
<div class="ui hidden divider"></div>
<span role="button" @click="isUpdating = true">
<i class="pencil icon"></i>
<div class="ui hidden divider" />
<span
role="button"
@click="isUpdating = true"
>
<i class="pencil icon" />
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</span>
</template>
<form v-if="isUpdating" class="ui form" @submit.prevent="submit()">
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/Channels/Error message.Title">Error while updating description</translate></h4>
<form
v-if="isUpdating"
class="ui form"
@submit.prevent="submit()"
>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/Channels/Error message.Title">
Error while updating description
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<content-form v-model="newText" :autofocus="true"></content-form>
<a @click.prevent="isUpdating = false" class="left floated">
<content-form
v-model="newText"
:autofocus="true"
/>
<a
class="left floated"
@click.prevent="isUpdating = false"
>
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
</a>
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']" type="submit" :disabled="isLoading">
<translate translate-context="Content/Channels/Button.Label/Verb">Update description</translate>
<button
:class="['ui', {'loading': isLoading}, 'right', 'floated', 'button']"
type="submit"
:disabled="isLoading"
>
<translate translate-context="Content/Channels/Button.Label/Verb">
Update description
</translate>
</button>
<div class="ui clearing hidden divider"></div>
<div class="ui clearing hidden divider" />
</form>
</div>
</template>
<script>
import {secondsToObject} from '@/filters'
import axios from 'axios'
import clip from 'text-clipper'
export default {
props: {
content: {required: true},
fieldName: {required: false, default: 'description'},
updateUrl: {required: false, type: String},
canUpdate: {required: false, default: true, type: Boolean},
fetchHtml: {required: false, default: false, type: Boolean},
permissive: {required: false, default: false, type: Boolean},
truncateLength: {required: false, default: 500, type: Number},
content: { type: String, required: true },
fieldName: { type: String, required: false, default: 'description' },
updateUrl: { required: false, type: String, default: '' },
canUpdate: { required: false, default: true, type: Boolean },
fetchHtml: { required: false, default: false, type: Boolean },
permissive: { required: false, default: false, type: Boolean },
truncateLength: { required: false, default: 500, type: Number }
},
data () {
return {
isUpdating: false,
showMore: false,
newText: (this.content || {text: ''}).text,
errors: null,
newText: (this.content || { text: '' }).text,
isLoading: false,
errors: [],
preview: null
}
},
async created () {
if (this.fetchHtml) {
await this.fetchPreview()
}
},
computed: {
html () {
if (this.fetchHtml) {
@ -91,21 +125,26 @@ export default {
return this.truncateLength > 0 && this.truncatedHtml.length < this.content.html.length
}
},
async created () {
if (this.fetchHtml) {
await this.fetchPreview()
}
},
methods: {
async fetchPreview () {
let response = await axios.post('text-preview/', {text: this.content.text, permissive: this.permissive})
const response = await axios.post('text-preview/', { text: this.content.text, permissive: this.permissive })
this.preview = response.data.rendered
},
submit () {
let self = this
const self = this
this.isLoading = true
this.errors = []
let payload = {}
const payload = {}
payload[this.fieldName] = null
if (this.newText) {
payload[this.fieldName] = {
content_type: "text/markdown",
text: this.newText,
content_type: 'text/markdown',
text: this.newText
}
}
axios.patch(this.updateUrl, payload).then((response) => {
@ -116,7 +155,7 @@ export default {
self.errors = error.backendErrors
self.isLoading = false
})
},
}
}
}
</script>

View File

@ -1,12 +1,15 @@
<template>
<span class="tooltip" :data-tooltip="content"><i class="question circle icon"></i></span>
<span
class="tooltip"
:data-tooltip="content"
><i class="question circle icon" /></span>
</template>
<script>
export default {
props: {
content: {type: String, required: true},
content: { type: String, required: true }
}
}
</script>

View File

@ -2,11 +2,16 @@
<span class="component-user-link">
<template v-if="avatar">
<img
v-if="user.avatar && user.avatar.urls.medium_square_crop"
v-lazy="$store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)"
class="ui tiny circular avatar"
alt=""
v-if="user.avatar && user.avatar.urls.medium_square_crop"
v-lazy="$store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop)" />
<span v-else :style="defaultAvatarStyle" class="ui circular label">{{ user.username[0]}}</span>
>
<span
v-else
:style="defaultAvatarStyle"
class="ui circular label"
>{{ user.username[0] }}</span>
&nbsp;
</template>
@{{ user.username }}
@ -14,12 +19,12 @@
</template>
<script>
import {hashCode, intToRGB} from '@/utils/color'
import { hashCode, intToRGB } from '@/utils/color'
export default {
props: {
user: {required: true},
avatar: {type: Boolean, default: true}
user: { type: String, required: true },
avatar: { type: Boolean, default: true }
},
computed: {
userColor () {

View File

@ -101,12 +101,12 @@
</template>
<div class="row">
<a
class="column"
href="https://funkwhale.audio/help"
target="_blank"
>
<i class="user-modal list-icon life ring outline icon" />
<span class="user-modal list-item">{{ labels.help }}</span>
class="column"
href="https://funkwhale.audio/help"
target="_blank"
>
<i class="user-modal list-icon life ring outline icon" />
<span class="user-modal list-item">{{ labels.help }}</span>
</a>
</div>
<div class="row">

View File

@ -3,6 +3,6 @@
</template>
<script>
export default {
props: ['username']
props: { username: { type: String, required: true } }
}
</script>

View File

@ -1,98 +1,158 @@
<template>
<main class="main pusher" v-title="labels.title">
<main
v-title="labels.title"
class="main pusher"
>
<section class="ui vertical center aligned stripe segment">
<div :class="['ui', {'active': isLoading}, 'inverted', 'dimmer']">
<div class="ui text loader">
<translate translate-context="Content/Favorites/Message">Loading your favorites</translate>
<translate translate-context="Content/Favorites/Message">
Loading your favorites
</translate>
</div>
</div>
<h2 v-if="results" class="ui center aligned icon header">
<i class="circular inverted heart pink icon"></i>
<h2
v-if="results"
class="ui center aligned icon header"
>
<i class="circular inverted heart pink icon" />
<translate
translate-plural="%{ count } favorites"
:translate-n="$store.state.favorites.count"
:translate-params="{count: results.count}"
translate-context="Content/Favorites/Title">
%{ count } favorite
translate-context="Content/Favorites/Title"
>
%{ count } favorite
</translate>
</h2>
<radio-button v-if="hasFavorites" type="favorites"></radio-button>
<radio-button
v-if="hasFavorites"
type="favorites"
/>
</section>
<section v-if="hasFavorites" class="ui vertical stripe segment">
<section
v-if="hasFavorites"
class="ui vertical stripe segment"
>
<div :class="['ui', {'loading': isLoading}, 'form']">
<div class="fields">
<div class="field">
<label for="favorites-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select id="favorites-ordering" class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]" :key="option[0]">
<select
id="favorites-ordering"
v-model="ordering"
class="ui dropdown"
>
<option
v-for="option in orderingOptions"
:key="option[0]"
:value="option[0]"
>
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label for="favorites-ordering-direction"><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
<select id="favorites-ordering-direction" class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
<select
id="favorites-ordering-direction"
v-model="orderingDirection"
class="ui dropdown"
>
<option value="+">
<translate translate-context="Content/Search/Dropdown">
Ascending
</translate>
</option>
<option value="-">
<translate translate-context="Content/Search/Dropdown">
Descending
</translate>
</option>
</select>
</div>
<div class="field">
<label for="favorites-results"><translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate></label>
<select id="favorites-results" class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(25)">25</option>
<option :value="parseInt(50)">50</option>
<select
id="favorites-results"
v-model="paginateBy"
class="ui dropdown"
>
<option :value="parseInt(12)">
12
</option>
<option :value="parseInt(25)">
25
</option>
<option :value="parseInt(50)">
50
</option>
</select>
</div>
</div>
</div>
<track-table :show-artist="true" :show-album="true" v-if="results" :tracks="results.results"></track-table>
<track-table
v-if="results"
:show-artist="true"
:show-album="true"
:tracks="results.results"
/>
<div class="ui center aligned basic segment">
<pagination
v-if="results && results.count > paginateBy"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="results.count"
></pagination>
@page-changed="selectPage"
/>
</div>
</section>
<div v-else class="ui placeholder segment">
<div
v-else
class="ui placeholder segment"
>
<div class="ui icon header">
<i class="broken heart icon"></i>
<i class="broken heart icon" />
<translate
translate-context="Content/Home/Placeholder"
>No tracks have been added to your favorites yet</translate>
>
No tracks have been added to your favorites yet
</translate>
</div>
<router-link :to="'/library'" class="ui success labeled icon button">
<i class="headphones icon"></i>
<translate translate-context="Content/*/Verb">Browse the library</translate>
<router-link
:to="'/library'"
class="ui success labeled icon button"
>
<i class="headphones icon" />
<translate translate-context="Content/*/Verb">
Browse the library
</translate>
</router-link>
</div>
</main>
</template>
<script>
import axios from "axios"
import $ from "jquery"
import logger from "@/logging"
import RadioButton from "@/components/radios/Button"
import Pagination from "@/components/Pagination"
import OrderingMixin from "@/components/mixins/Ordering"
import PaginationMixin from "@/components/mixins/Pagination"
import TranslationsMixin from "@/components/mixins/Translations"
import {checkRedirectToLogin} from '@/utils'
import axios from 'axios'
import $ from 'jquery'
import logger from '@/logging'
import RadioButton from '@/components/radios/Button'
import Pagination from '@/components/Pagination'
import OrderingMixin from '@/components/mixins/Ordering'
import PaginationMixin from '@/components/mixins/Pagination'
import TranslationsMixin from '@/components/mixins/Translations'
import { checkRedirectToLogin } from '@/utils'
import TrackTable from '@/components/audio/track/Table'
const FAVORITES_URL = "tracks/"
const FAVORITES_URL = 'tracks/'
export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
components: {
RadioButton,
Pagination,
TrackTable
},
data() {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
data () {
return {
results: null,
isLoading: false,
@ -100,33 +160,46 @@ export default {
previousLink: null,
page: parseInt(this.defaultPage),
orderingOptions: [
["creation_date", "creation_date"],
["title", "track_title"],
["album__title", "album_title"],
["artist__name", "artist_name"]
['creation_date', 'creation_date'],
['title', 'track_title'],
['album__title', 'album_title'],
['artist__name', 'artist_name']
]
}
},
created() {
checkRedirectToLogin(this.$store, this.$router)
this.fetchFavorites(FAVORITES_URL)
},
mounted() {
$(".ui.dropdown").dropdown()
},
computed: {
labels() {
labels () {
return {
title: this.$pgettext('Head/Favorites/Title', 'Your Favorites')
}
},
hasFavorites () {
return this.$store.state.favorites.count > 0
}
},
watch: {
page: function () {
this.updateQueryString()
},
paginateBy: function () {
this.updateQueryString()
},
orderingDirection: function () {
this.updateQueryString()
},
ordering: function () {
this.updateQueryString()
}
},
created () {
checkRedirectToLogin(this.$store, this.$router)
this.fetchFavorites(FAVORITES_URL)
},
mounted () {
$('.ui.dropdown').dropdown()
},
methods: {
updateQueryString: function() {
updateQueryString: function () {
this.$router.replace({
query: {
page: this.page,
@ -136,44 +209,30 @@ export default {
})
this.fetchFavorites(FAVORITES_URL)
},
fetchFavorites(url) {
var self = this
fetchFavorites (url) {
const self = this
this.isLoading = true
let params = {
favorites: "true",
const params = {
favorites: 'true',
page: this.page,
page_size: this.paginateBy,
ordering: this.getOrderingAsString()
}
logger.default.time("Loading user favorites")
logger.default.time('Loading user favorites')
axios.get(url, { params: params }).then(response => {
self.results = response.data
self.nextLink = response.data.next
self.previousLink = response.data.previous
self.results.results.forEach(track => {
self.$store.commit("favorites/track", { id: track.id, value: true })
self.$store.commit('favorites/track', { id: track.id, value: true })
})
logger.default.timeEnd("Loading user favorites")
logger.default.timeEnd('Loading user favorites')
self.isLoading = false
})
},
selectPage: function(page) {
selectPage: function (page) {
this.page = page
}
},
watch: {
page: function() {
this.updateQueryString()
},
paginateBy: function() {
this.updateQueryString()
},
orderingDirection: function() {
this.updateQueryString()
},
ordering: function() {
this.updateQueryString()
}
}
}
</script>

View File

@ -1,25 +1,40 @@
<template>
<button @click.stop="$store.dispatch('favorites/toggle', track.id)" v-if="button" :class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']">
<i class="heart icon"></i>
<translate v-if="isFavorite" translate-context="Content/Track/Button.Message">In favorites</translate>
<translate v-else translate-context="Content/Track/*/Verb">Add to favorites</translate>
<template>
<button
v-if="button"
:class="['ui', 'pink', {'inverted': isFavorite}, {'favorited': isFavorite}, 'icon', 'labeled', 'button']"
@click.stop="$store.dispatch('favorites/toggle', track.id)"
>
<i class="heart icon" />
<translate
v-if="isFavorite"
translate-context="Content/Track/Button.Message"
>
In favorites
</translate>
<translate
v-else
translate-context="Content/Track/*/Verb"
>
Add to favorites
</translate>
</button>
<button
v-else
@click.stop="$store.dispatch('favorites/toggle', track.id)"
:class="['ui', 'favorite-icon', {'pink': isFavorite}, {'favorited': isFavorite}, 'basic', 'circular', 'icon', {'really': !border}, 'button']"
:aria-label="title"
:title="title">
<i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']"></i>
:title="title"
@click.stop="$store.dispatch('favorites/toggle', track.id)"
>
<i :class="['heart', {'pink': isFavorite}, 'basic', 'icon']" />
</button>
</template>
<script>
export default {
props: {
track: {type: Object},
button: {type: Boolean, default: false},
border: {type: Boolean, default: false},
track: { type: Object, default: () => { return {} } },
button: { type: Boolean, default: false },
border: { type: Boolean, default: false }
},
computed: {
title () {

View File

@ -1,30 +1,73 @@
<template>
<div @click="createFetch" role="button">
<div
role="button"
@click="createFetch"
>
<div>
<slot></slot>
<slot />
</div>
<modal class="small" :show.sync="showModal">
<modal
class="small"
:show.sync="showModal"
>
<h3 class="header">
<translate translate-context="Popup/*/Title">Refreshing object from remote server</translate>
<translate translate-context="Popup/*/Title">
Refreshing object from remote server
</translate>
</h3>
<div class="scrolling content">
<template v-if="fetch && fetch.status != 'pending'">
<div v-if="fetch.status === 'skipped'" class="ui message">
<h4 class="header"><translate translate-context="Popup/*/Message.Title">Refresh was skipped</translate></h4>
<p><translate translate-context="Popup/*/Message.Content">The remote server answered, but returned data was unsupported by Funkwhale.</translate></p>
<div
v-if="fetch.status === 'skipped'"
class="ui message"
>
<h4 class="header">
<translate translate-context="Popup/*/Message.Title">
Refresh was skipped
</translate>
</h4>
<p>
<translate translate-context="Popup/*/Message.Content">
The remote server answered, but returned data was unsupported by Funkwhale.
</translate>
</p>
</div>
<div v-else-if="fetch.status === 'finished'" class="ui success message">
<h4 class="header"><translate translate-context="Popup/*/Message.Title">Refresh successful</translate></h4>
<p><translate translate-context="Popup/*/Message.Content">Data was refreshed successfully from remote server.</translate></p>
<div
v-else-if="fetch.status === 'finished'"
class="ui success message"
>
<h4 class="header">
<translate translate-context="Popup/*/Message.Title">
Refresh successful
</translate>
</h4>
<p>
<translate translate-context="Popup/*/Message.Content">
Data was refreshed successfully from remote server.
</translate>
</p>
</div>
<div v-else-if="fetch.status === 'errored'" class="ui error message">
<h4 class="header"><translate translate-context="Popup/*/Message.Title">Refresh error</translate></h4>
<p><translate translate-context="Popup/*/Message.Content">An error occurred while trying to refresh data:</translate></p>
<div
v-else-if="fetch.status === 'errored'"
class="ui error message"
>
<h4 class="header">
<translate translate-context="Popup/*/Message.Title">
Refresh error
</translate>
</h4>
<p>
<translate translate-context="Popup/*/Message.Content">
An error occurred while trying to refresh data:
</translate>
</p>
<table class="ui very basic collapsing celled table">
<tbody>
<tr>
<td>
<translate translate-context="Popup/Import/Table.Label/Noun">Error type</translate>
<translate translate-context="Popup/Import/Table.Label/Noun">
Error type
</translate>
</td>
<td>
{{ fetch.detail.error_code }}
@ -32,61 +75,136 @@
</tr>
<tr>
<td>
<translate translate-context="Popup/Import/Table.Label/Noun">Error detail</translate>
<translate translate-context="Popup/Import/Table.Label/Noun">
Error detail
</translate>
</td>
<td>
<translate
v-if="fetch.detail.error_code === 'http' && fetch.detail.status_code"
:translate-params="{status: fetch.detail.status_code}"
translate-context="*/*/Error">The remote server answered with HTTP %{ status }</translate>
translate-context="*/*/Error"
>
The remote server answered with HTTP %{ status }
</translate>
<translate
v-else-if="['http', 'request'].indexOf(fetch.detail.error_code) > -1"
translate-context="*/*/Error">An HTTP error occurred while contacting the remote server</translate>
translate-context="*/*/Error"
>
An HTTP error occurred while contacting the remote server
</translate>
<translate
v-else-if="fetch.detail.error_code === 'timeout'"
translate-context="*/*/Error">The remote server didn't respond quickly enough</translate>
translate-context="*/*/Error"
>
The remote server didn't respond quickly enough
</translate>
<translate
v-else-if="fetch.detail.error_code === 'connection'"
translate-context="*/*/Error">Impossible to connect to the remote server</translate>
translate-context="*/*/Error"
>
Impossible to connect to the remote server
</translate>
<translate
v-else-if="['invalid_json', 'invalid_jsonld', 'missing_jsonld_type'].indexOf(fetch.detail.error_code) > -1"
translate-context="*/*/Error">The remote server returned invalid JSON or JSON-LD data</translate>
<translate v-else-if="fetch.detail.error_code === 'validation'" translate-context="*/*/Error">Data returned by the remote server had invalid or missing attributes</translate>
<translate v-else-if="fetch.detail.error_code === 'unhandled'" translate-context="*/*/Error">Unknown error</translate>
<translate v-else translate-context="*/*/Error">Unknown error</translate>
translate-context="*/*/Error"
>
The remote server returned invalid JSON or JSON-LD data
</translate>
<translate
v-else-if="fetch.detail.error_code === 'validation'"
translate-context="*/*/Error"
>
Data returned by the remote server had invalid or missing attributes
</translate>
<translate
v-else-if="fetch.detail.error_code === 'unhandled'"
translate-context="*/*/Error"
>
Unknown error
</translate>
<translate
v-else
translate-context="*/*/Error"
>
Unknown error
</translate>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div v-else-if="isCreatingFetch" class="ui active inverted dimmer">
<div
v-else-if="isCreatingFetch"
class="ui active inverted dimmer"
>
<div class="ui text loader">
<translate translate-context="Popup/*/Loading.Title">Requesting a fetch</translate>
<translate translate-context="Popup/*/Loading.Title">
Requesting a fetch
</translate>
</div>
</div>
<div v-else-if="isWaitingFetch" class="ui active inverted dimmer">
<div
v-else-if="isWaitingFetch"
class="ui active inverted dimmer"
>
<div class="ui text loader">
<translate translate-context="Popup/*/Loading.Title">Waiting for result</translate>
<translate translate-context="Popup/*/Loading.Title">
Waiting for result
</translate>
</div>
</div>
<div v-if="errors.length > 0" role="alert" class="ui negative message">
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Error while saving settings</translate></h4>
<div
v-if="errors.length > 0"
role="alert"
class="ui negative message"
>
<h4 class="header">
<translate translate-context="Content/*/Error message.Title">
Error while saving settings
</translate>
</h4>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</div>
<div v-else-if="fetch && fetch.status === 'pending' && pollsCount >= maxPolls" role="alert" class="ui warning message">
<h4 class="header"><translate translate-context="Popup/*/Message.Title">Refresh pending</translate></h4>
<p><translate translate-context="Popup/*/Message.Content">The refresh request hasn't been processed in time by our server. It will be processed later.</translate></p>
<div
v-else-if="fetch && fetch.status === 'pending' && pollsCount >= maxPolls"
role="alert"
class="ui warning message"
>
<h4 class="header">
<translate translate-context="Popup/*/Message.Title">
Refresh pending
</translate>
</h4>
<p>
<translate translate-context="Popup/*/Message.Content">
The refresh request hasn't been processed in time by our server. It will be processed later.
</translate>
</p>
</div>
</div>
<div class="actions">
<button class="ui basic cancel button">
<translate translate-context="*/*/Button.Label/Verb">Close</translate>
<translate translate-context="*/*/Button.Label/Verb">
Close
</translate>
</button>
<button @click.prevent="showModal = false; $emit('refresh')" class="ui confirm success button" v-if="fetch && fetch.status === 'finished'">
<translate translate-context="*/*/Button.Label/Verb">Close and reload page</translate>
<button
v-if="fetch && fetch.status === 'finished'"
class="ui confirm success button"
@click.prevent="showModal = false; $emit('refresh')"
>
<translate translate-context="*/*/Button.Label/Verb">
Close and reload page
</translate>
</button>
</div>
</modal>
@ -94,14 +212,14 @@
</template>
<script>
import axios from "axios"
import axios from 'axios'
import Modal from '@/components/semantic/Modal'
export default {
props: ['url'],
components: {
Modal
},
props: { url: { type: String, required: true } },
data () {
return {
fetch: null,
@ -110,12 +228,12 @@ export default {
showModal: false,
isWaitingFetch: false,
maxPolls: 15,
pollsCount: 0,
pollsCount: 0
}
},
methods: {
createFetch () {
let self = this
const self = this
this.fetch = null
this.pollsCount = 0
this.errors = []
@ -134,8 +252,8 @@ export default {
pollFetch () {
this.isWaitingFetch = true
this.pollsCount += 1
let url = `federation/fetches/${this.fetch.id}/`
let self = this
const url = `federation/fetches/${this.fetch.id}/`
const self = this
self.showModal = true
axios.get(url).then((response) => {
self.isCreatingFetch = false

View File

@ -1,27 +1,52 @@
<template>
<div class="wrapper">
<h3 v-if="!!this.$slots.title" class="ui header">
<slot name="title"></slot>
<h3
v-if="!!$slots.title"
class="ui header"
>
<slot name="title" />
</h3>
<p v-if="!isLoading && libraries.length > 0" class="ui subtitle"><slot name="subtitle"></slot></p>
<p v-if="!isLoading && libraries.length === 0" class="ui subtitle"><translate translate-context="Content/Federation/Paragraph">No matching library.</translate></p>
<div class="ui hidden divider"></div>
<p
v-if="!isLoading && libraries.length > 0"
class="ui subtitle"
>
<slot name="subtitle" />
</p>
<p
v-if="!isLoading && libraries.length === 0"
class="ui subtitle"
>
<translate translate-context="Content/Federation/Paragraph">
No matching library.
</translate>
</p>
<div class="ui hidden divider" />
<div class="ui cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
<div
v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div>
<library-card
v-for="library in libraries"
:key="library.uuid"
:display-scan="false"
:display-follow="$store.state.auth.authenticated && library.actor.full_username != $store.state.auth.fullUsername"
:library="library"
:display-copy-fid="true"
v-for="library in libraries"
:key="library.uuid"></library-card>
/>
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
<translate translate-context="*/*/Button,Label">Show more</translate>
<div class="ui hidden divider" />
<button
v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button>
</template>
</div>
@ -33,12 +58,12 @@ import axios from 'axios'
import LibraryCard from '@/views/content/remote/Card'
export default {
props: {
url: {type: String, required: true}
},
components: {
LibraryCard
},
props: {
url: { type: String, required: true }
},
data () {
return {
libraries: [],
@ -49,17 +74,22 @@ export default {
nextPage: null
}
},
watch: {
offset () {
this.fetchData()
}
},
created () {
this.fetchData(this.url)
},
methods: {
fetchData (url) {
this.isLoading = true
let self = this
let params = _.clone({})
const self = this
const params = _.clone({})
params.page_size = this.limit
params.offset = this.offset
axios.get(url, {params: params}).then((response) => {
axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
@ -77,11 +107,6 @@ export default {
this.offset = Math.max(this.offset - this.limit, 0)
}
}
},
watch: {
offset () {
this.fetchData()
}
}
}
</script>

View File

@ -1,55 +1,60 @@
<template>
<div class="ui fluid action input">
<input
:id="fieldId"
required
name="password"
:type="passwordInputType"
@input="$emit('input', $event.target.value)"
:id="fieldId"
:value="value"
/>
@input="$emit('input', $event.target.value)"
>
<button
@click.prevent="showPassword = !showPassword"
type="button"
:title="labels.title"
class="ui icon button"
@click.prevent="showPassword = !showPassword"
>
<i class="eye icon"></i>
<i class="eye icon" />
</button>
<button
v-if="copyButton"
@click.prevent="copyPassword"
type="button"
class="ui icon button"
:title="labels.copy"
@click.prevent="copyPassword"
>
<i class="copy icon"></i>
<i class="copy icon" />
</button>
</div>
</template>
<script>
export default {
props: ["value", "defaultShow", "copyButton", "fieldId"],
data() {
props: {
value: { type: String, required: true },
defaultShow: { type: Boolean, default: false },
copyButton: { type: Boolean, default: false },
fieldId: { type: Number, default: 0 }
},
data () {
return {
showPassword: this.defaultShow || false,
};
showPassword: this.defaultShow || false
}
},
computed: {
labels () {
return {
title: this.$pgettext(
"Content/Settings/Button.Tooltip/Verb",
"Show/hide password"
'Content/Settings/Button.Tooltip/Verb',
'Show/hide password'
),
copy: this.$pgettext("*/*/Button.Label/Short, Verb", "Copy"),
copy: this.$pgettext('*/*/Button.Label/Short, Verb', 'Copy')
}
},
passwordInputType() {
passwordInputType () {
if (this.showPassword) {
return "text";
return 'text'
}
return "password";
return 'password'
}
},
methods: {

View File

@ -1,23 +1,23 @@
import Vue from 'vue'
Vue.component('human-date', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDate"))
Vue.component('human-duration', () => import(/* webpackChunkName: "common" */ "@/components/common/HumanDuration"))
Vue.component('username', () => import(/* webpackChunkName: "common" */ "@/components/common/Username"))
Vue.component('user-link', () => import(/* webpackChunkName: "common" */ "@/components/common/UserLink"))
Vue.component('actor-link', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorLink"))
Vue.component('actor-avatar', () => import(/* webpackChunkName: "common" */ "@/components/common/ActorAvatar"))
Vue.component('duration', () => import(/* webpackChunkName: "common" */ "@/components/common/Duration"))
Vue.component('dangerous-button', () => import(/* webpackChunkName: "common" */ "@/components/common/DangerousButton"))
Vue.component('message', () => import(/* webpackChunkName: "common" */ "@/components/common/Message"))
Vue.component('copy-input', () => import(/* webpackChunkName: "common" */ "@/components/common/CopyInput"))
Vue.component('ajax-button', () => import(/* webpackChunkName: "common" */ "@/components/common/AjaxButton"))
Vue.component('tooltip', () => import(/* webpackChunkName: "common" */ "@/components/common/Tooltip"))
Vue.component('empty-state', () => import(/* webpackChunkName: "common" */ "@/components/common/EmptyState"))
Vue.component('expandable-div', () => import(/* webpackChunkName: "common" */ "@/components/common/ExpandableDiv"))
Vue.component('collapse-link', () => import(/* webpackChunkName: "common" */ "@/components/common/CollapseLink"))
Vue.component('action-feedback', () => import(/* webpackChunkName: "common" */ "@/components/common/ActionFeedback"))
Vue.component('rendered-description', () => import(/* webpackChunkName: "common" */ "@/components/common/RenderedDescription"))
Vue.component('content-form', () => import(/* webpackChunkName: "common" */ "@/components/common/ContentForm"))
Vue.component('inline-search-bar', () => import(/* webpackChunkName: "common" */ "@/components/common/InlineSearchBar"))
Vue.component('HumanDate', () => import(/* webpackChunkName: "common" */ '@/components/common/HumanDate'))
Vue.component('HumanDuration', () => import(/* webpackChunkName: "common" */ '@/components/common/HumanDuration'))
Vue.component('Username', () => import(/* webpackChunkName: "common" */ '@/components/common/Username'))
Vue.component('UserLink', () => import(/* webpackChunkName: "common" */ '@/components/common/UserLink'))
Vue.component('ActorLink', () => import(/* webpackChunkName: "common" */ '@/components/common/ActorLink'))
Vue.component('ActorAvatar', () => import(/* webpackChunkName: "common" */ '@/components/common/ActorAvatar'))
Vue.component('Duration', () => import(/* webpackChunkName: "common" */ '@/components/common/Duration'))
Vue.component('DangerousButton', () => import(/* webpackChunkName: "common" */ '@/components/common/DangerousButton'))
Vue.component('Message', () => import(/* webpackChunkName: "common" */ '@/components/common/Message'))
Vue.component('CopyInput', () => import(/* webpackChunkName: "common" */ '@/components/common/CopyInput'))
Vue.component('AjaxButton', () => import(/* webpackChunkName: "common" */ '@/components/common/AjaxButton'))
Vue.component('Tooltip', () => import(/* webpackChunkName: "common" */ '@/components/common/Tooltip'))
Vue.component('EmptyState', () => import(/* webpackChunkName: "common" */ '@/components/common/EmptyState'))
Vue.component('ExpandableDiv', () => import(/* webpackChunkName: "common" */ '@/components/common/ExpandableDiv'))
Vue.component('CollapseLink', () => import(/* webpackChunkName: "common" */ '@/components/common/CollapseLink'))
Vue.component('ActionFeedback', () => import(/* webpackChunkName: "common" */ '@/components/common/ActionFeedback'))
Vue.component('RenderedDescription', () => import(/* webpackChunkName: "common" */ '@/components/common/RenderedDescription'))
Vue.component('ContentForm', () => import(/* webpackChunkName: "common" */ '@/components/common/ContentForm'))
Vue.component('InlineSearchBar', () => import(/* webpackChunkName: "common" */ '@/components/common/InlineSearchBar'))
export default {}

View File

@ -1,39 +1,90 @@
<template>
<main>
<div v-if="isLoading" class="ui vertical segment" v-title="labels.title">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
<div
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">
<div v-if="isSerie" class="padded basic segment">
<div class="ui two column grid" v-if="isSerie">
<div
v-if="isSerie"
class="padded basic segment"
>
<div
v-if="isSerie"
class="ui two column grid"
>
<div class="column">
<div class="large two-images">
<img alt="" class="channel-image" v-if="object.cover && object.cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)">
<img alt="" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img alt="" class="channel-image" v-if="object.cover && object.cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)">
<img alt="" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img
v-if="object.cover && object.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
alt=""
class="channel-image"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<img
v-if="object.cover && object.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
alt=""
class="channel-image"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
</div>
</div>
<div class="ui column right aligned">
<tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
<div class="ui small hidden divider"></div>
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
<tags-list
v-if="object.tags && object.tags.length > 0"
:tags="object.tags"
/>
<div class="ui small hidden divider" />
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<template v-if="totalTracks > 0">
<div class="ui hidden very small divider"></div>
<translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
<div class="ui hidden very small divider" />
<translate
v-if="isSerie"
key="1"
translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="totalTracks"
:translate-params="{count: totalTracks}">
:translate-params="{count: totalTracks}"
>
%{ count } episode
</translate>
<translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate>
<translate
v-else
translate-context="*/*/*"
:translate-params="{count: totalTracks}"
:translate-n="totalTracks"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate>
</template>
<div class="ui small hidden divider"></div>
<play-button class="vibrant" :tracks="object.tracks"></play-button>
<div class="ui hidden horizontal divider"></div>
<div class="ui small hidden divider" />
<play-button
class="vibrant"
:tracks="object.tracks"
/>
<div class="ui hidden horizontal divider" />
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
@ -41,42 +92,86 @@
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"></album-dropdown>
:artist="artist"
/>
</div>
</div>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<header>
<h2 class="ui header" :title="object.title">
<h2
class="ui header"
:title="object.title"
>
{{ object.title }}
</h2>
<artist-label :artist="artist"></artist-label>
<artist-label :artist="artist" />
</header>
</div>
<div v-else class="ui center aligned text padded basic segment">
<img alt="" class="channel-image" v-if="object.cover && object.cover.urls.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)">
<img alt="" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<div class="ui hidden divider"></div>
<div
v-else
class="ui center aligned text padded basic segment"
>
<img
v-if="object.cover && object.cover.urls.original"
v-lazy="$store.getters['instance/absoluteUrl'](object.cover.urls.medium_square_crop)"
alt=""
class="channel-image"
>
<img
v-else
alt=""
class="channel-image"
src="../../assets/audio/default-cover.png"
>
<div class="ui hidden divider" />
<header>
<h2 class="ui header" :title="object.title">
<h2
class="ui header"
:title="object.title"
>
{{ object.title }}
</h2>
<artist-label class="rounded" :artist="artist"></artist-label>
<artist-label
class="rounded"
:artist="artist"
/>
</header>
<div v-if="object.release_date || (totalTracks > 0)" class="ui small hidden divider"></div>
<div
v-if="object.release_date || (totalTracks > 0)"
class="ui small hidden divider"
/>
<span v-if="object.release_date">{{ object.release_date | moment('Y') }} · </span>
<template v-if="totalTracks > 0">
<translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
<translate
v-if="isSerie"
key="1"
translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="totalTracks"
:translate-params="{count: totalTracks}">
:translate-params="{count: totalTracks}"
>
%{ count } episode
</translate>
<translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate> ·
<translate
v-else
translate-context="*/*/*"
:translate-params="{count: totalTracks}"
:translate-n="totalTracks"
translate-plural="%{ count } tracks"
>
%{ count } track
</translate> ·
</template>
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
<div class="ui small hidden divider"></div>
<play-button class="vibrant" :album="object"></play-button>
<div class="ui horizontal hidden divider"></div>
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<div class="ui small hidden divider" />
<play-button
class="vibrant"
:album="object"
/>
<div class="ui horizontal hidden divider" />
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
@ -85,40 +180,64 @@
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"
></album-dropdown>
/>
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
<div class="ui small hidden divider"></div>
<div class="ui divider"></div>
<div class="ui small hidden divider"></div>
<template v-if="object.tags && object.tags.length > 0" >
<tags-list :tags="object.tags"></tags-list>
<div class="ui small hidden divider"></div>
<div class="ui small hidden divider" />
<div class="ui divider" />
<div class="ui small hidden divider" />
<template v-if="object.tags && object.tags.length > 0">
<tags-list :tags="object.tags" />
<div class="ui small hidden divider" />
</template>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="false"></rendered-description>
<router-link v-else-if="$store.state.auth.authenticated && object.is_local" :to="{name: 'library.albums.edit', params: {id: object.id }}">
<i class="pencil icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Add a description</translate>
:can-update="false"
/>
<router-link
v-else-if="$store.state.auth.authenticated && object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
>
<i class="pencil icon" />
<translate translate-context="Content/*/Button.Label/Verb">
Add a description
</translate>
</router-link>
</div>
</div>
<template v-if="isSerie">
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="false"></rendered-description>
<router-link v-else-if="$store.state.auth.authenticated && object.is_local" :to="{name: 'library.albums.edit', params: {id: object.id }}">
<i class="pencil icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Add a description</translate>
:can-update="false"
/>
<router-link
v-else-if="$store.state.auth.authenticated && object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
>
<i class="pencil icon" />
<translate translate-context="Content/*/Button.Label/Verb">
Add a description
</translate>
</router-link>
</template>
</div>
<div class="nine wide column">
<router-view v-if="object" :paginate-by="paginateBy" :page="page" :total-tracks="totalTracks" :is-serie="isSerie" :artist="artist" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath" @page-changed="page = $event"></router-view>
<router-view
v-if="object"
:key="$route.fullPath"
:paginate-by="paginateBy"
:page="page"
:total-tracks="totalTracks"
:is-serie="isSerie"
:artist="artist"
:discs="discs"
:object="object"
object-type="album"
@libraries-loaded="libraries = $event"
@page-changed="page = $event"
/>
</div>
</div>
</section>
@ -127,17 +246,17 @@
</template>
<script>
import axios from "axios"
import lodash from "@/lodash"
import PlayButton from "@/components/audio/PlayButton"
import TagsList from "@/components/tags/List"
import axios from 'axios'
import lodash from '@/lodash'
import PlayButton from '@/components/audio/PlayButton'
import TagsList from '@/components/tags/List'
import ArtistLabel from '@/components/audio/ArtistLabel'
import AlbumDropdown from './AlbumDropdown'
function groupByDisc(initial) {
function inner(acc, track) {
var dn = track.disc_number - initial
if (acc[dn] == undefined) {
function groupByDisc (initial) {
function inner (acc, track) {
const dn = track.disc_number - initial
if (acc[dn] === undefined) {
acc.push([track])
} else {
acc[dn].push(track)
@ -148,14 +267,14 @@ function groupByDisc(initial) {
}
export default {
props: ["id"],
components: {
PlayButton,
TagsList,
ArtistLabel,
AlbumDropdown
},
data() {
props: { id: { type: Number, required: true } },
data () {
return {
isLoading: true,
object: null,
@ -163,39 +282,7 @@ export default {
discs: [],
libraries: [],
page: 1,
paginateBy: 50,
}
},
async created() {
await this.fetchData()
},
methods: {
async fetchData() {
this.isLoading = true
let tracksResponse = axios.get(`tracks/`, {params: {ordering: 'disc_number,position', album: this.id, page_size: this.paginateBy, page:this.page, include_channels: 'true', playable: 'true'}})
let albumResponse = await axios.get(`albums/${this.id}/`, {params: {refresh: 'true'}})
let artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
this.artist = artistResponse.data
if (this.artist.channel) {
this.artist.channel.artist = this.artist
}
tracksResponse = await tracksResponse
this.object = albumResponse.data
this.object.tracks = tracksResponse.data.results
this.discs = this.object.tracks.reduce(groupByDisc(this.object.tracks[0].disc_number), [])
this.isLoading = false
},
remove () {
let self = this
self.isLoading = true
axios.delete(`albums/${this.object.id}`).then((response) => {
self.isLoading = false
self.$emit('deleted')
self.$router.push({name: 'library.artists.detail', params: {id: this.artist.id}})
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
paginateBy: 50
}
},
computed: {
@ -212,7 +299,7 @@ export default {
return this.object.artist.content_category === 'music'
},
totalDuration () {
let durations = [0]
const durations = [0]
this.object.tracks.forEach((t) => {
if (t.uploads[0] && t.uploads[0].duration) {
durations.push(t.uploads[0].duration)
@ -220,24 +307,56 @@ export default {
})
return lodash.sum(durations)
},
labels() {
labels () {
return {
title: this.$pgettext('*/*/*', 'Album'),
title: this.$pgettext('*/*/*', 'Album')
}
},
publicLibraries () {
return this.libraries.filter(l => {
return l.privacy_level === 'everyone'
})
},
}
},
watch: {
id() {
id () {
this.fetchData()
},
page() {
page () {
this.fetchData()
}
},
async created () {
await this.fetchData()
},
methods: {
async fetchData () {
this.isLoading = true
let tracksResponse = axios.get('tracks/', { params: { ordering: 'disc_number,position', album: this.id, page_size: this.paginateBy, page: this.page, include_channels: 'true', playable: 'true' } })
const albumResponse = await axios.get(`albums/${this.id}/`, { params: { refresh: 'true' } })
const artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
this.artist = artistResponse.data
if (this.artist.channel) {
this.artist.channel.artist = this.artist
}
tracksResponse = await tracksResponse
this.object = albumResponse.data
this.object.tracks = tracksResponse.data.results
this.discs = this.object.tracks.reduce(groupByDisc(this.object.tracks[0].disc_number), [])
this.isLoading = false
},
remove () {
const self = this
self.isLoading = true
axios.delete(`albums/${this.object.id}`).then((response) => {
self.isLoading = false
self.$emit('deleted')
self.$router.push({ name: 'library.artists.detail', params: { id: this.artist.id } })
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
}
}
}
</script>

View File

@ -1,21 +1,45 @@
<template>
<div v-if="object">
<h2 class="ui header">
<translate key="1" v-if="isSerie" translate-context="Content/Channels/*">Episodes</translate>
<translate key="2" v-else translate-context="*/*/*">Tracks</translate>
<translate
v-if="isSerie"
key="1"
translate-context="Content/Channels/*"
>
Episodes
</translate>
<translate
v-else
key="2"
translate-context="*/*/*"
>
Tracks
</translate>
</h2>
<channel-entries v-if="artist.channel && isSerie" :is-podcast="isSerie" :limit="50" :filters="{channel: artist.channel.uuid, album: object.id, ordering: '-creation_date'}">
</channel-entries>
<channel-entries
v-if="artist.channel && isSerie"
:is-podcast="isSerie"
:limit="50"
:filters="{channel: artist.channel.uuid, album: object.id, ordering: '-creation_date'}"
/>
<template v-else-if="discs && discs.length > 1">
<div v-for="tracks in discs" :key="tracks.disc_number">
<div class="ui hidden divider"></div>
<play-button class="right floated mini inverted vibrant" :tracks="tracks"></play-button>
<div
v-for="tracks in discs"
:key="tracks.disc_number"
>
<div class="ui hidden divider" />
<play-button
class="right floated mini inverted vibrant"
:tracks="tracks"
/>
<translate
tag="h3"
:translate-params="{number: tracks[0].disc_number}"
translate-context="Content/Album/"
>Volume %{ number }</translate>
<track-table
>
Volume %{ number }
</translate>
<track-table
:is-album="true"
:tracks="object.tracks"
:show-position="true"
@ -26,12 +50,12 @@
:total="totalTracks"
:paginate-by="paginateBy"
:page="page"
@page-changed="updatePage">
</track-table>
@page-changed="updatePage"
/>
</div>
</template>
<template v-else>
<track-table
<track-table
:is-album="true"
:tracks="object.tracks"
:show-position="true"
@ -42,15 +66,25 @@
:total="totalTracks"
:paginate-by="paginateBy"
:page="page"
@page-changed="updatePage">
</track-table>
@page-changed="updatePage"
/>
</template>
<template v-if="!artist.channel && !isSerie">
<h2>
<translate translate-context="Content/*/Title/Noun">User libraries</translate>
<translate translate-context="Content/*/Title/Noun">
User libraries
</translate>
</h2>
<library-widget @loaded="$emit('libraries-loaded', $event)" :url="'albums/' + object.id + '/libraries/'">
<translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate>
<library-widget
:url="'albums/' + object.id + '/libraries/'"
@loaded="$emit('libraries-loaded', $event)"
>
<translate
slot="subtitle"
translate-context="Content/Album/Paragraph"
>
This album is present in the following libraries:
</translate>
</library-widget>
</template>
</div>
@ -58,30 +92,38 @@
<script>
import time from "@/utils/time"
import LibraryWidget from "@/components/federation/LibraryWidget"
import time from '@/utils/time'
import LibraryWidget from '@/components/federation/LibraryWidget'
import ChannelEntries from '@/components/audio/ChannelEntries'
import TrackTable from '@/components/audio/track/Table'
import PlayButton from "@/components/audio/PlayButton"
import PlayButton from '@/components/audio/PlayButton'
export default {
props: ["object", "libraries", "discs", "isSerie", "artist", "page", "paginateBy", "totalTracks"],
components: {
LibraryWidget,
TrackTable,
ChannelEntries,
PlayButton
},
data() {
props: {
object: { type: Object, required: true },
discs: { type: Array, required: true },
isSerie: { type: Boolean, required: true },
artist: { type: Object, required: true },
page: { type: Number, required: true },
paginateBy: { type: Number, required: true },
totalTracks: { type: Number, required: true }
},
data () {
return {
time,
id: this.object.id,
id: this.object.id
}
},
methods: {
updatePage: function(page) {
updatePage: function (page) {
this.$emit('page-changed', page)
}
},
}
}
</script>

View File

@ -1,13 +1,19 @@
<template>
<span>
<modal v-if="isEmbedable" :show.sync="showEmbedModal">
<modal
v-if="isEmbedable"
:show.sync="showEmbedModal"
>
<h4 class="header">
<translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate>
</h4>
<div class="scrolling content">
<div class="description">
<embed-wizard type="album" :id="object.id" />
<embed-wizard
:id="object.id"
type="album"
/>
</div>
</div>
@ -17,46 +23,69 @@
</button>
</div>
</modal>
<button class="ui floating dropdown circular icon basic button" :title="labels.more" v-dropdown="{direction: 'downward'}">
<i class="ellipsis vertical icon"></i>
<button
v-dropdown="{direction: 'downward'}"
class="ui floating dropdown circular icon basic button"
:title="labels.more"
>
<i class="ellipsis vertical icon" />
<div class="menu">
<a
:href="object.fid"
v-if="domain != $store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="basic item">
<i class="external icon"></i>
<translate :translate-params="{domain: domain}" translate-context="Content/*/Button.Label/Verb">View on %{ domain }</translate>
class="basic item"
>
<i class="external icon" />
<translate
:translate-params="{domain: domain}"
translate-context="Content/*/Button.Label/Verb"
>View on %{ domain }</translate>
</a>
<div
role="button"
v-if="isEmbedable"
role="button"
class="basic item"
@click="showEmbedModal = !showEmbedModal"
class="basic item">
<i class="code icon"></i>
>
<i class="code icon" />
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
</div>
<a v-if="isAlbum && musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<a
v-if="isAlbum && musicbrainzUrl"
:href="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a>
<a v-if="!isChannel && isAlbum" :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<a
v-if="!isChannel && isAlbum"
:href="discogsUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
</a>
</a>
<router-link
v-if="object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
class="basic item">
<i class="edit icon"></i>
class="basic item"
>
<i class="edit icon" />
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<dangerous-button
:class="['ui', {loading: isLoading}, 'item']"
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
@confirm="remove()">
<i class="ui trash icon"></i>
:class="['ui', {loading: isLoading}, 'item']"
@confirm="remove()"
>
<i class="ui trash icon" />
<translate translate-context="*/*/*/Verb">Delete</translate>
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
<div slot="modal-content">
@ -64,26 +93,33 @@
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
<div class="divider"></div>
<div class="divider" />
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({album: object, channel: artist.channel})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
role="button"
class="basic item"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
>
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
<i class="wrench icon"></i>
<div class="divider" />
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="basic item"
:to="{name: 'manage.library.albums.detail', params: {id: object.id}}"
>
<i class="wrench icon" />
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<a
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
class="basic item"
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
target="_blank" rel="noopener noreferrer">
<i class="wrench icon"></i>
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
</a>
</div>
@ -91,30 +127,30 @@
</span>
</template>
<script>
import EmbedWizard from "@/components/audio/EmbedWizard"
import EmbedWizard from '@/components/audio/EmbedWizard'
import Modal from '@/components/semantic/Modal'
import ReportMixin from '@/components/mixins/Report'
import {getDomain} from '@/utils'
import { getDomain } from '@/utils'
export default {
components: {
EmbedWizard,
Modal
},
mixins: [ReportMixin],
props: {
isLoading: Boolean,
artist: Object,
object: Object,
publicLibraries: Array,
artist: { type: Object, required: true },
object: { type: Object, required: true },
publicLibraries: { type: Array, required: true },
isAlbum: Boolean,
isChannel: Boolean,
isSerie: Boolean,
},
components: {
EmbedWizard,
Modal,
isSerie: Boolean
},
data () {
return {
showEmbedModal: false,
showEmbedModal: false
}
},
computed: {
@ -122,28 +158,30 @@ export default {
if (this.object) {
return getDomain(this.object.fid)
}
return null
},
labels() {
labels () {
return {
more: this.$pgettext('*/*/Button.Label/Noun', "More…"),
more: this.$pgettext('*/*/Button.Label/Noun', 'More…')
}
},
isEmbedable () {
return (this.isChannel && this.artist.channel.actor) || this.publicLibraries.length > 0
},
musicbrainzUrl() {
musicbrainzUrl () {
if (this.object.mbid) {
return "https://musicbrainz.org/release/" + this.object.mbid
return 'https://musicbrainz.org/release/' + this.object.mbid
}
return null
},
discogsUrl() {
discogsUrl () {
return (
"https://discogs.com/search/?type=release&title=" +
encodeURI(this.object.title) + "&artist=" +
'https://discogs.com/search/?type=release&title=' +
encodeURI(this.object.title) + '&artist=' +
encodeURI(this.object.artist.name)
)
},
}
}
}
</script>

View File

@ -1,37 +1,56 @@
<template>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h2>
<translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this album</translate>
<translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this album</translate>
<translate
v-if="canEdit"
key="1"
translate-context="Content/*/Title"
>
Edit this album
</translate>
<translate
v-else
key="2"
translate-context="Content/*/Title"
>
Suggest an edit on this album
</translate>
</h2>
<div class="ui message" v-if="!object.is_local">
<translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
<div
v-if="!object.is_local"
class="ui message"
>
<translate translate-context="Content/*/Message">
This object is managed by another server, you cannot edit it.
</translate>
</div>
<edit-form
v-else
:object-type="objectType"
:object="object"
:can-edit="canEdit"></edit-form>
:can-edit="canEdit"
/>
</div>
</section>
</template>
<script>
import axios from "axios"
import EditForm from '@/components/library/EditForm'
export default {
props: ["objectType", "object", "libraries"],
data() {
return {
id: this.object.id,
}
},
components: {
EditForm
},
props: {
objectType: { type: String, required: true },
object: { type: Object, required: true },
libraries: { type: Array, required: true }
},
data () {
return {
id: this.object.id
}
},
computed: {
canEdit () {
return true

View File

@ -2,78 +2,131 @@
<main v-title="labels.title">
<section class="ui vertical stripe segment">
<h2 class="ui header">
<translate translate-context="Content/Album/Title">Browsing albums</translate>
<translate translate-context="Content/Album/Title">
Browsing albums
</translate>
</h2>
<form :class="['ui', {'loading': isLoading}, 'form']" @submit.prevent="updatePage();updateQueryString();fetchData()">
<form
:class="['ui', {'loading': isLoading}, 'form']"
@submit.prevent="updatePage();updateQueryString();fetchData()"
>
<div class="fields">
<div class="field">
<label for="albums-search">
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
</label>
<div class="ui action input">
<input id="albums-search" type="text" name="search" v-model="query" :placeholder="labels.searchPlaceholder"/>
<button class="ui icon button" type="submit" :aria-label="$pgettext('Content/Search/Input.Label/Noun', 'Search')">
<i class="search icon"></i>
<input
id="albums-search"
v-model="query"
type="text"
name="search"
:placeholder="labels.searchPlaceholder"
>
<button
class="ui icon button"
type="submit"
:aria-label="$pgettext('Content/Search/Input.Label/Noun', 'Search')"
>
<i class="search icon" />
</button>
</div>
</div>
<div class="field">
<label for="tags-search"><translate translate-context="*/*/*/Noun">Tags</translate></label>
<tags-selector v-model="tags"></tags-selector>
<tags-selector v-model="tags" />
</div>
<div class="field">
<label for="album-ordering"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select id="album-ordering" class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
<select
id="album-ordering"
v-model="ordering"
class="ui dropdown"
>
<option
v-for="(option, key) in orderingOptions"
:key="key"
:value="option[0]"
>
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label for="album-ordering-direction"><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label>
<select id="album-ordering-direction" class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
<option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
<select
id="album-ordering-direction"
v-model="orderingDirection"
class="ui dropdown"
>
<option value="+">
<translate translate-context="Content/Search/Dropdown">
Ascending
</translate>
</option>
<option value="-">
<translate translate-context="Content/Search/Dropdown">
Descending
</translate>
</option>
</select>
</div>
<div class="field">
<label for="album-results"><translate translate-context="Content/Search/Dropdown.Label/Noun">Results per page</translate></label>
<select id="album-results" class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(25)">25</option>
<option :value="parseInt(50)">50</option>
<select
id="album-results"
v-model="paginateBy"
class="ui dropdown"
>
<option :value="parseInt(12)">
12
</option>
<option :value="parseInt(25)">
25
</option>
<option :value="parseInt(50)">
50
</option>
</select>
</div>
</div>
</form>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
<div
v-if="result"
transition-duration="0"
item-selector=".column"
percent-position="true"
stagger="0"
class="">
class=""
>
<div
v-if="result.results.length > 0"
class="ui app-cards cards">
class="ui app-cards cards"
>
<album-card
v-for="album in result.results"
:key="album.id"
:album="album"></album-card>
:album="album"
/>
</div>
<div v-else class="ui placeholder segment sixteen wide column" style="text-align: center; display: flex; align-items: center">
<div
v-else
class="ui placeholder segment sixteen wide column"
style="text-align: center; display: flex; align-items: center"
>
<div class="ui icon header">
<i class="compact disc icon"></i>
<i class="compact disc icon" />
<translate translate-context="Content/Albums/Placeholder">
No results matching your query
</translate>
</div>
<router-link
v-if="$store.state.auth.authenticated"
:to="{name: 'content.index'}"
class="ui success button labeled icon">
<i class="upload icon"></i>
v-if="$store.state.auth.authenticated"
:to="{name: 'content.index'}"
class="ui success button labeled icon"
>
<i class="upload icon" />
<translate translate-context="Content/*/Verb">
Add some music
</translate>
@ -83,11 +136,11 @@
<div class="ui center aligned basic segment">
<pagination
v-if="result && result.count > paginateBy"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
@page-changed="selectPage"
/>
</div>
</section>
</main>
@ -95,121 +148,120 @@
<script>
import qs from 'qs'
import axios from "axios"
import _ from "@/lodash"
import $ from "jquery"
import axios from 'axios'
import $ from 'jquery'
import logger from "@/logging"
import logger from '@/logging'
import OrderingMixin from "@/components/mixins/Ordering"
import PaginationMixin from "@/components/mixins/Pagination"
import TranslationsMixin from "@/components/mixins/Translations"
import AlbumCard from "@/components/audio/album/Card"
import Pagination from "@/components/Pagination"
import OrderingMixin from '@/components/mixins/Ordering'
import PaginationMixin from '@/components/mixins/Pagination'
import TranslationsMixin from '@/components/mixins/Translations'
import AlbumCard from '@/components/audio/album/Card'
import Pagination from '@/components/Pagination'
import TagsSelector from '@/components/library/TagsSelector'
const FETCH_URL = "albums/"
const FETCH_URL = 'albums/'
export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: {
defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } },
scope: { type: String, required: false, default: "all" },
},
components: {
AlbumCard,
Pagination,
TagsSelector,
TagsSelector
},
data() {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: {
defaultQuery: { type: String, required: false, default: '' },
defaultTags: { type: Array, required: false, default: () => { return [] } },
scope: { type: String, required: false, default: 'all' }
},
data () {
return {
isLoading: true,
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
tags: (this.defaultTags || []).filter((t) => { return t.length > 0 }),
orderingOptions: [["creation_date", "creation_date"], ["title", "album_title"],["release_date","release_date"]]
orderingOptions: [['creation_date', 'creation_date'], ['title', 'album_title'], ['release_date', 'release_date']]
}
},
created() {
this.fetchData()
},
mounted() {
$(".ui.dropdown").dropdown()
},
computed: {
labels() {
let searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', "Enter album title…")
let title = this.$pgettext('*/*/*', "Albums")
labels () {
const searchPlaceholder = this.$pgettext('Content/Search/Input.Placeholder', 'Enter album title…')
const title = this.$pgettext('*/*/*', 'Albums')
return {
searchPlaceholder,
title
}
}
},
watch: {
page () {
this.updateQueryString()
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
}
},
created () {
this.fetchData()
},
mounted () {
$('.ui.dropdown').dropdown()
},
methods: {
updateQueryString: function() {
updateQueryString: function () {
history.pushState(
{},
null,
this.$route.path + '?' + new URLSearchParams(
{
query: this.query,
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}).toString()
query: this.query,
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}).toString()
)
},
fetchData: function() {
var self = this
fetchData: function () {
const self = this
this.isLoading = true
let url = FETCH_URL
let params = {
const url = FETCH_URL
const params = {
scope: this.scope,
page: this.page,
page_size: this.paginateBy,
q: this.query,
ordering: this.getOrderingAsString(),
playable: "true",
playable: 'true',
tag: this.tags,
include_channels: "true",
content_category: "music"
include_channels: 'true',
content_category: 'music'
}
logger.default.debug("Fetching albums")
logger.default.debug('Fetching albums')
axios.get(
url,
{
params: params,
paramsSerializer: function(params) {
paramsSerializer: function (params) {
return qs.stringify(params, { indices: false })
}
}
).then(response => {
self.result = response.data
self.isLoading = false
}, error => {
}, () => {
self.result = null
self.isLoading = false
})
},
selectPage: function(page) {
selectPage: function (page) {
this.page = page
},
updatePage() {
updatePage () {
this.page = this.defaultPage
}
},
watch: {
page() {
this.updateQueryString()
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
}
}
}
</script>

View File

@ -1,119 +1,195 @@
<template>
<main v-title="labels.title">
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
<div
v-if="isLoading"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div>
<template v-if="object && !isLoading">
<section :class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.name">
<section
v-title="object.name"
:class="['ui', 'head', {'with-background': cover}, 'vertical', 'center', 'aligned', 'stripe', 'segment']"
:style="headerStyle"
>
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted users violet icon"></i>
<i class="circular inverted users violet icon" />
<div class="content">
{{ object.name }}
<div class="sub header" v-if="albums">
<translate translate-context="Content/Artist/Paragraph"
<div
v-if="albums"
class="sub header"
>
<translate
translate-context="Content/Artist/Paragraph"
tag="div"
translate-plural="%{ count } tracks in %{ albumsCount } albums"
:translate-n="totalTracks"
:translate-params="{count: totalTracks, albumsCount: totalAlbums}">
:translate-params="{count: totalTracks, albumsCount: totalAlbums}"
>
%{ count } track in %{ albumsCount } albums
</translate>
</div>
</div>
</h2>
<tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
<div class="ui hidden divider"></div>
<tags-list
v-if="object.tags && object.tags.length > 0"
:tags="object.tags"
/>
<div class="ui hidden divider" />
<div class="header-buttons">
<div class="ui buttons">
<radio-button type="artist" :object-id="object.id"></radio-button>
<radio-button
type="artist"
:object-id="object.id"
/>
</div>
<div class="ui buttons">
<play-button :is-playable="isPlayable" class="vibrant" :artist="object">
<translate translate-context="Content/Artist/Button.Label/Verb">Play all albums</translate>
<play-button
:is-playable="isPlayable"
class="vibrant"
:artist="object"
>
<translate translate-context="Content/Artist/Button.Label/Verb">
Play all albums
</translate>
</play-button>
</div>
<modal :show.sync="showEmbedModal" v-if="publicLibraries.length > 0">
<modal
v-if="publicLibraries.length > 0"
:show.sync="showEmbedModal"
>
<h4 class="header">
<translate translate-context="Popup/Artist/Title/Verb">Embed this artist work on your website</translate>
<translate translate-context="Popup/Artist/Title/Verb">
Embed this artist work on your website
</translate>
</h4>
<div class="scrolling content">
<div class="description">
<embed-wizard type="artist" :id="object.id" />
<embed-wizard
:id="object.id"
type="artist"
/>
</div>
</div>
<div class="actions">
<button class="ui deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
<translate translate-context="*/*/Button.Label/Verb">
Cancel
</translate>
</button>
</div>
</modal>
<div class="ui buttons">
<button class="ui button" @click="$refs.dropdown.click()">
<translate translate-context="*/*/Button.Label/Noun">More</translate>
<button
class="ui button"
@click="$refs.dropdown.click()"
>
<translate translate-context="*/*/Button.Label/Noun">
More
</translate>
</button>
<button class="ui floating dropdown icon button" ref="dropdown" v-dropdown>
<i class="dropdown icon"></i>
<button
ref="dropdown"
v-dropdown
class="ui floating dropdown icon button"
>
<i class="dropdown icon" />
<div class="menu">
<a
:href="object.fid"
v-if="domain != $store.getters['instance/domain']"
:href="object.fid"
target="_blank"
class="basic item">
<i class="external icon"></i>
<translate :translate-params="{domain: domain}" translate-context="Content/*/Button.Label/Verb">View on %{ domain }</translate>
class="basic item"
>
<i class="external icon" />
<translate
:translate-params="{domain: domain}"
translate-context="Content/*/Button.Label/Verb"
>View on %{ domain }</translate>
</a>
<button
role="button"
v-if="publicLibraries.length > 0"
role="button"
class="basic item"
@click.prevent="showEmbedModal = !showEmbedModal"
class="basic item">
<i class="code icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
>
<i class="code icon" />
<translate translate-context="Content/*/Button.Label/Verb">
Embed
</translate>
</button>
<a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="wikipedia w icon"></i>
<a
:href="wikipediaUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="wikipedia w icon" />
<translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate>
</a>
<a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<a
v-if="musicbrainzUrl"
:href="musicbrainzUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a>
<a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
</a>
<a
:href="discogsUrl"
target="_blank"
rel="noreferrer noopener"
class="basic item"
>
<i class="external icon" />
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
</a>
<router-link
v-if="object.is_local"
:to="{name: 'library.artists.edit', params: {id: object.id }}"
class="basic item">
<i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<div class="divider"></div>
<div
role="button"
class="basic item"
>
<i class="edit icon" />
<translate translate-context="Content/*/Button.Label/Verb">
Edit
</translate>
</router-link>
<div class="divider" />
<div
v-for="obj in getReportableObjs({artist: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
role="button"
class="basic item"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
>
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.artists.detail', params: {id: object.id}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
<div class="divider" />
<router-link
v-if="$store.state.auth.availablePermissions['library']"
class="basic item"
:to="{name: 'manage.library.artists.detail', params: {id: object.id}}"
>
<i class="wrench icon" />
<translate translate-context="Content/Moderation/Link">
Open in moderation interface
</translate>
</router-link>
<a
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
class="basic item"
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/artist/${object.id}`)"
target="_blank" rel="noopener noreferrer">
<i class="wrench icon"></i>
target="_blank"
rel="noopener noreferrer"
>
<i class="wrench icon" />
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
</a>
</div>
@ -123,44 +199,43 @@
</div>
</section>
<router-view
:key="$route.fullPath"
:tracks="tracks"
:next-tracks-url="nextTracksUrl"
:next-albums-url="nextAlbumsUrl"
:albums="albums"
:is-loading-albums="isLoadingAlbums"
:object="object"
object-type="artist"
@libraries-loaded="libraries = $event"
:object="object" object-type="artist"
:key="$route.fullPath"></router-view>
/>
</template>
</main>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import backend from "@/audio/backend"
import PlayButton from "@/components/audio/PlayButton"
import EmbedWizard from "@/components/audio/EmbedWizard"
import axios from 'axios'
import logger from '@/logging'
import PlayButton from '@/components/audio/PlayButton'
import EmbedWizard from '@/components/audio/EmbedWizard'
import Modal from '@/components/semantic/Modal'
import RadioButton from "@/components/radios/Button"
import TagsList from "@/components/tags/List"
import RadioButton from '@/components/radios/Button'
import TagsList from '@/components/tags/List'
import ReportMixin from '@/components/mixins/Report'
import {getDomain} from '@/utils'
const FETCH_URL = "albums/"
import { getDomain } from '@/utils'
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
EmbedWizard,
Modal,
RadioButton,
TagsList,
TagsList
},
data() {
mixins: [ReportMixin],
props: { id: { type: Number, required: true } },
data () {
return {
isLoading: true,
isLoadingAlbums: true,
@ -172,47 +247,7 @@ export default {
nextAlbumsUrl: null,
nextTracksUrl: null,
totalAlbums: null,
totalTracks: null,
}
},
async created() {
await this.fetchData()
},
methods: {
async fetchData() {
var self = this
this.isLoading = true
logger.default.debug('Fetching artist "' + this.id + '"')
let artistPromise = axios.get("artists/" + this.id + "/", {params: {refresh: 'true'}}).then(response => {
if (response.data.channel) {
self.$router.replace({name: 'channels.detail', params: {id: response.data.channel.uuid}})
} else {
self.object = response.data
}
})
await artistPromise
if (!self.object) {
return
}
let trackPromise = axios.get("tracks/", { params: { artist: this.id, hidden: '', ordering: "-creation_date" } }).then(response => {
self.tracks = response.data.results
self.nextTracksUrl = response.data.next
self.totalTracks = response.data.count
})
let albumPromise = axios.get("albums/", {
params: { artist: self.id, ordering: "-release_date", hidden: '' }
}).then(response => {
self.nextAlbumsUrl = response.data.next
self.totalAlbums = response.data.count
let parsed = JSON.parse(JSON.stringify(response.data.results))
self.albums = parsed
})
await trackPromise
await albumPromise
self.isLoadingAlbums = false
self.isLoading = false
totalTracks: null
}
},
computed: {
@ -220,37 +255,39 @@ export default {
if (this.object) {
return getDomain(this.object.fid)
}
return null
},
isPlayable() {
isPlayable () {
return (
this.object.albums.filter(a => {
return a.is_playable
}).length > 0
)
},
labels() {
labels () {
return {
title: this.$pgettext('*/*/*', 'Album')
}
},
wikipediaUrl() {
wikipediaUrl () {
return (
"https://en.wikipedia.org/w/index.php?search=" +
'https://en.wikipedia.org/w/index.php?search=' +
encodeURI(this.object.name)
)
},
musicbrainzUrl() {
musicbrainzUrl () {
if (this.object.mbid) {
return "https://musicbrainz.org/artist/" + this.object.mbid
return 'https://musicbrainz.org/artist/' + this.object.mbid
}
return null
},
discogsUrl() {
discogsUrl () {
return (
"https://discogs.com/search/?type=artist&title=" +
encodeURI(this.object.name)
'https://discogs.com/search/?type=artist&title=' +
encodeURI(this.object.name)
)
},
cover() {
cover () {
if (this.object.cover && this.object.cover.urls.original) {
return this.object.cover
}
@ -268,27 +305,65 @@ export default {
return l.privacy_level === 'everyone'
})
},
headerStyle() {
headerStyle () {
if (!this.cover || !this.cover.urls.original) {
return ""
return ''
}
return (
"background-image: url(" +
this.$store.getters["instance/absoluteUrl"](this.cover.urls.original) +
")"
'background-image: url(' +
this.$store.getters['instance/absoluteUrl'](this.cover.urls.original) +
')'
)
},
contentFilter () {
let self = this
return this.$store.getters['moderation/artistFilters']().filter((e) => {
return e.target.id === this.object.id
})[0]
}
},
watch: {
id() {
id () {
this.fetchData()
}
},
async created () {
await this.fetchData()
},
methods: {
async fetchData () {
const self = this
this.isLoading = true
logger.default.debug('Fetching artist "' + this.id + '"')
const artistPromise = axios.get('artists/' + this.id + '/', { params: { refresh: 'true' } }).then(response => {
if (response.data.channel) {
self.$router.replace({ name: 'channels.detail', params: { id: response.data.channel.uuid } })
} else {
self.object = response.data
}
})
await artistPromise
if (!self.object) {
return
}
const trackPromise = axios.get('tracks/', { params: { artist: this.id, hidden: '', ordering: '-creation_date' } }).then(response => {
self.tracks = response.data.results
self.nextTracksUrl = response.data.next
self.totalTracks = response.data.count
})
const albumPromise = axios.get('albums/', {
params: { artist: self.id, ordering: '-release_date', hidden: '' }
}).then(response => {
self.nextAlbumsUrl = response.data.next
self.totalAlbums = response.data.count
const parsed = JSON.parse(JSON.stringify(response.data.results))
self.albums = parsed
})
await trackPromise
await albumPromise
self.isLoadingAlbums = false
self.isLoading = false
}
}
}
</script>

View File

@ -1,69 +1,127 @@
<template>
<div v-if="object">
<div class="ui small text container" v-if="contentFilter">
<div class="ui hidden divider"></div>
<div
v-if="contentFilter"
class="ui small text container"
>
<div class="ui hidden divider" />
<div class="ui message">
<p>
<translate translate-context="Content/Artist/Paragraph">You are currently hiding content related to this artist.</translate>
<translate translate-context="Content/Artist/Paragraph">
You are currently hiding content related to this artist.
</translate>
</p>
<router-link class="right floated" :to="{name: 'settings'}">
<translate translate-context="Content/Moderation/Link">Review my filters</translate>
<router-link
class="right floated"
:to="{name: 'settings'}"
>
<translate translate-context="Content/Moderation/Link">
Review my filters
</translate>
</router-link>
<button @click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)" class="ui basic tiny button">
<translate translate-context="Content/Moderation/Button.Label">Remove filter</translate>
<button
class="ui basic tiny button"
@click="$store.dispatch('moderation/deleteContentFilter', contentFilter.uuid)"
>
<translate translate-context="Content/Moderation/Button.Label">
Remove filter
</translate>
</button>
</div>
</div>
<section v-if="tracks.length > 0" class="ui vertical stripe segment">
<track-table :is-artist="true" :show-position="false" :track-only="true" :tracks="tracks.slice(0,5)">
<section
v-if="tracks.length > 0"
class="ui vertical stripe segment"
>
<track-table
:is-artist="true"
:show-position="false"
:track-only="true"
:tracks="tracks.slice(0,5)"
>
<template slot="header">
<h2>
<translate translate-context="Content/Artist/Title">New tracks by this artist</translate>
<translate translate-context="Content/Artist/Title">
New tracks by this artist
</translate>
</h2>
<div class="ui hidden divider"></div>
<div class="ui hidden divider" />
</template>
</track-table>
</section>
<section v-if="isLoadingAlbums" class="ui vertical stripe segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
<section
v-if="isLoadingAlbums"
class="ui vertical stripe segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</section>
<section v-else-if="albums && albums.length > 0" class="ui vertical stripe segment">
<section
v-else-if="albums && albums.length > 0"
class="ui vertical stripe segment"
>
<h2>
<translate translate-context="Content/Artist/Title">Albums by this artist</translate>
<translate translate-context="Content/Artist/Title">
Albums by this artist
</translate>
</h2>
<div class="ui cards app-cards">
<album-card :album="album" :key="album.id" v-for="album in allAlbums"></album-card>
<album-card
v-for="album in allAlbums"
:key="album.id"
:album="album"
/>
</div>
<div class="ui hidden divider"></div>
<button :class="['ui', {loading: isLoadingMoreAlbums}, 'button']" v-if="nextAlbumsUrl && loadMoreAlbumsUrl" @click="loadMoreAlbums(loadMoreAlbumsUrl)">
<translate translate-context="Content/*/Button.Label">Load more</translate>
<div class="ui hidden divider" />
<button
v-if="nextAlbumsUrl && loadMoreAlbumsUrl"
:class="['ui', {loading: isLoadingMoreAlbums}, 'button']"
@click="loadMoreAlbums(loadMoreAlbumsUrl)"
>
<translate translate-context="Content/*/Button.Label">
Load more
</translate>
</button>
</section>
<section class="ui vertical stripe segment">
<h2>
<translate translate-context="Content/*/Title/Noun">User libraries</translate>
<translate translate-context="Content/*/Title/Noun">
User libraries
</translate>
</h2>
<library-widget @loaded="$emit('libraries-loaded', $event)" :url="'artists/' + object.id + '/libraries/'">
<translate translate-context="Content/Artist/Paragraph" slot="subtitle">This artist is present in the following libraries:</translate>
<library-widget
:url="'artists/' + object.id + '/libraries/'"
@loaded="$emit('libraries-loaded', $event)"
>
<translate
slot="subtitle"
translate-context="Content/Artist/Paragraph"
>
This artist is present in the following libraries:
</translate>
</library-widget>
</section>
</div>
</template>
<script>
import _ from "@/lodash"
import axios from "axios"
import logger from "@/logging"
import AlbumCard from "@/components/audio/album/Card"
import TrackTable from "@/components/audio/track/Table"
import LibraryWidget from "@/components/federation/LibraryWidget"
import axios from 'axios'
import AlbumCard from '@/components/audio/album/Card'
import TrackTable from '@/components/audio/track/Table'
import LibraryWidget from '@/components/federation/LibraryWidget'
export default {
props: ["object", "tracks", "albums", "isLoadingAlbums", "nextTracksUrl", "nextAlbumsUrl"],
components: {
AlbumCard,
TrackTable,
LibraryWidget,
LibraryWidget
},
props: {
object: { type: Object, required: true },
tracks: { type: Array, required: true },
albums: { type: Array, required: true },
isLoadingAlbums: { type: Boolean, required: true },
nextTracksUrl: { type: String, required: true },
nextAlbumsUrl: { type: String, required: true }
},
data () {
return {
@ -74,26 +132,24 @@ export default {
},
computed: {
contentFilter () {
let self = this
return this.$store.getters['moderation/artistFilters']().filter((e) => {
return e.target.id === this.object.id
})[0]
},
allAlbums () {
allAlbums () {
return this.albums.concat(this.additionalAlbums)
}
},
methods: {
loadMoreAlbums (url) {
let self = this
const self = this
self.isLoadingMoreAlbums = true
axios.get(url).then((response) => {
self.additionalAlbums = self.additionalAlbums.concat(response.data.results)
self.loadMoreAlbumsUrl = response.data.next
self.isLoadingMoreAlbums = false
}, (error) => {
}, () => {
self.isLoadingMoreAlbums = false
})
}
}

View File

@ -1,37 +1,56 @@
<template>
<section class="ui vertical stripe segment">
<div class="ui text container">
<h2>
<translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this artist</translate>
<translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this artist</translate>
<translate
v-if="canEdit"
key="1"
translate-context="Content/*/Title"
>
Edit this artist
</translate>
<translate
v-else
key="2"
translate-context="Content/*/Title"
>
Suggest an edit on this artist
</translate>
</h2>
<div class="ui message" v-if="!object.is_local">
<translate translate-context="Content/*/Message">This object is managed by another server, you cannot edit it.</translate>
<div
v-if="!object.is_local"
class="ui message"
>
<translate translate-context="Content/*/Message">
This object is managed by another server, you cannot edit it.
</translate>
</div>
<edit-form
v-else
:object-type="objectType"
:object="object"
:can-edit="canEdit"></edit-form>
:can-edit="canEdit"
/>
</div>
</section>
</template>
<script>
import axios from "axios"
import EditForm from '@/components/library/EditForm'
export default {
props: ["objectType", "object", "libraries"],
data() {
return {
id: this.object.id,
}
},
components: {
EditForm
},
props: {
objectType: { type: String, required: true },
object: { type: Object, required: true },
libraries: { type: Array, required: true }
},
data () {
return {
id: this.object.id
}
},
computed: {
canEdit () {
return true

Some files were not shown because too many files have changed in this diff Show More