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 - cd front
- yarn install - yarn install
script: script:
# We search for all files ending with .vue or .js in src which changed in relation to develop - yarn lint
# 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' ' ')
cache: cache:
key: "$CI_PROJECT_ID__eslint_npm_cache" key: "$CI_PROJECT_ID__eslint_npm_cache"
paths: paths:

View File

@ -20,5 +20,7 @@ module.exports = {
'vue' 'vue'
], ],
rules: { 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}", "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", "build": "scripts/i18n-compile.sh && vue-cli-service build",
"test:unit": "vue-cli-service test:unit --reporter mocha-junit-reporter", "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-compile": "scripts/i18n-compile.sh",
"i18n-extract": "scripts/i18n-extract.sh", "i18n-extract": "scripts/i18n-extract.sh",
"fix-fomantic-css": "scripts/fix-fomantic-css.sh", "fix-fomantic-css": "scripts/fix-fomantic-css.sh",

View File

@ -2,57 +2,110 @@
<template> <template>
<main :class="[theme]"> <main :class="[theme]">
<!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg --> <!-- 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"> <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> aria-hidden="true"
<symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"/></symbol> style="display: none"
<symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"/></symbol> xmlns="http://www.w3.org/2000/svg"
<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-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-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-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z" /></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-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z" /></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-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z" /></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-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-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z"/></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-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-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-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> <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 --> <!-- 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-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-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> </svg>
<article> <article>
<aside class="cover main" v-if="currentTrack"> <aside
<img height="120" v-if="currentTrack.cover" :src="currentTrack.cover" alt="Cover" /> v-if="currentTrack"
<img height="120" v-else src="./assets/embed/default-cover.jpeg" alt="Cover" /> 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> </aside>
<div class="content" aria-label="Track information"> <div
class="content"
aria-label="Track information"
>
<header v-if="currentTrack"> <header v-if="currentTrack">
<h3><a :href="fullUrl('/library/tracks/' + currentTrack.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.title }}</a></h3> <h3>
<a :href="fullUrl('/library/artists/' + currentTrack.artist.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.artist.name }}</a> <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> </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"> <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"> <div class="plyr__controls">
<button <button
type="button"
class="plyr__control"
aria-label="Play previous track"
@focus="setControlFocus($event, true)" @focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)" @blur="setControlFocus($event, false)"
@click="previous()" @click="previous()"
type="button" >
class="plyr__control" <svg
aria-label="Play previous track"> class="icon--not-pressed"
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80"> role="presentation"
<use xlink:href="#plyr-step-backward"></use> focusable="false"
viewBox="0 0 1100 1650"
width="80"
height="80"
>
<use xlink:href="#plyr-step-backward" />
</svg> </svg>
</button> </button>
<button <button
type="button"
class="plyr__control"
aria-label="Play next track"
@click="next()" @click="next()"
@focus="setControlFocus($event, true)" @focus="setControlFocus($event, true)"
@blur="setControlFocus($event, false)" @blur="setControlFocus($event, false)"
type="button" >
class="plyr__control" <svg
aria-label="Play next track"> class="icon--not-pressed"
<svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80"> role="presentation"
<use xlink:href="#plyr-step-forward"></use> focusable="false"
viewBox="0 0 1100 1650"
width="80"
height="80"
>
<use xlink:href="#plyr-step-forward" />
</svg> </svg>
</button> </button>
</div> </div>
@ -62,51 +115,122 @@
:key="currentIndex" :key="currentIndex"
ref="player" ref="player"
class="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"> <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> </audio>
</vue-plyr> </vue-plyr>
</template> </template>
<div v-else class="player"> <div
<span v-if="error === 'invalid_type'" class="error">Widget improperly configured (bad resource type {{ type }}).</span> v-else
<span v-else-if="error === 'invalid_id'" class="error">Widget improperly configured (missing resource id).</span> class="player"
<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
<span v-else-if="error === 'server_error'" class="error">A server error occurred.</span> v-if="error === 'invalid_type'"
<span v-else-if="error === 'server_error'" class="error">An unknown error occurred while loading track data from server.</span> class="error"
<span v-else-if="currentTrack && currentTrack.sources.length === 0" class="error">This track is unavailable.</span> >Widget improperly configured (bad resource type {{ type }}).</span>
<span v-else class="error">An unknown error occurred while loading track data.</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> </div>
<a title="Funkwhale" href="https://funkwhale.audio" target="_blank" rel="noopener noreferrer" class="logo-wrapper"> <a
<logo :fill="currentTheme.textColor" class="logo"></logo> title="Funkwhale"
href="https://funkwhale.audio"
target="_blank"
rel="noopener noreferrer"
class="logo-wrapper"
>
<logo
:fill="currentTheme.textColor"
class="logo"
/>
</a> </a>
</section> </section>
</div> </div>
</article> </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"> <table class="queue">
<tbody> <tbody>
<tr <tr
:id="'queue-item-' + index" v-for="(track, index) in tracks"
role="button"
v-if="track.sources.length > 0" v-if="track.sources.length > 0"
:id="'queue-item-' + index"
:key="index" :key="index"
role="button"
:class="[{active: index === currentIndex}]" :class="[{active: index === currentIndex}]"
@click="play(index)" @click="play(index)"
@keyup.enter="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"> <span class="position">
{{ index + 1 }} {{ index + 1 }}
</span> </span>
</td> </td>
<td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td> <td
<td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td> class="title"
<td class="album"> :title="track.title"
<div class="ellipsis" v-if="track.album" :title="track.album.title">{{ track.album.title }}</div> >
<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>
<td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -116,26 +240,24 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import Logo from "@/components/Logo" import Logo from '@/components/Logo'
import url from '@/utils/url' import url from '@/utils/url'
import time from '@/utils/time' import time from '@/utils/time'
function getURLParams () { function getURLParams () {
var urlParams let match
var match, const pl = /\+/g // Regex for replacing addition symbol with a space
pl = /\+/g, // Regex for replacing addition symbol with a space const urlParams = {}
search = /([^&=]+)=?([^&]*)/g, const search = /([^&=]+)=?([^&]*)/g
decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }, const decode = function (s) { return decodeURIComponent(s.replace(pl, ' ')) }
query = window.location.search.substring(1); 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 return urlParams
} }
export default { export default {
name: 'app', name: 'App',
components: {Logo}, components: { Logo },
data () { data () {
return { return {
time, time,
@ -152,13 +274,59 @@ export default {
currentIndex: -1, currentIndex: -1,
themes: { themes: {
dark: { 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 () { created () {
let params = getURLParams() const params = getURLParams()
this.baseUrl = params.b || '' this.baseUrl = params.b || ''
this.type = params.type this.type = params.type
if (this.supportedTypes.indexOf(this.type) === -1) { if (this.supportedTypes.indexOf(this.type) === -1) {
@ -172,44 +340,18 @@ export default {
this.isLoading = false this.isLoading = false
return return
} }
if (!!params.instance) { if (params.instance) {
this.baseUrl = 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) this.fetch(this.type, this.id)
}, },
mounted () { mounted () {
var parser = document.createElement('a') const parser = document.createElement('a')
parser.href = this.baseUrl parser.href = this.baseUrl
this.url = parser 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: { methods: {
next () { next () {
if (this.hasNext) { if (this.hasNext) {
@ -221,11 +363,11 @@ export default {
this.play(this.currentIndex - 1) this.play(this.currentIndex - 1)
} }
}, },
setControlFocus(event, enable) { setControlFocus (event, enable) {
if (enable) { if (enable) {
event.target.classList.add("plyr__tab-focus"); event.target.classList.add('plyr__tab-focus')
} else { } else {
event.target.classList.remove("plyr__tab-focus"); event.target.classList.remove('plyr__tab-focus')
} }
}, },
fetch (type, id) { fetch (type, id) {
@ -233,13 +375,13 @@ export default {
this.fetchTrack(id) this.fetchTrack(id)
} }
if (type === 'album') { 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') { 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') { 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') { if (type === 'playlist') {
this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`) this.fetchTracks({}, `/api/v1/playlists/${id}/tracks/`)
@ -247,67 +389,61 @@ export default {
}, },
play (index) { play (index) {
this.currentIndex = index this.currentIndex = index
let self = this const self = this
this.$nextTick(() => { this.$nextTick(() => {
self.$refs.player.player.play() self.$refs.player.player.play()
}) })
}, },
fetchTrack (id) { fetchTrack (id) {
let self = this const self = this
let url = `${this.baseUrl}/api/v1/tracks/${id}/` const url = `${this.baseUrl}/api/v1/tracks/${id}/`
axios.get(url).then(response => { axios.get(url).then(response => {
self.tracks = self.parseTracks([response.data]) self.tracks = self.parseTracks([response.data])
self.isLoading = false; self.isLoading = false
}).catch(error => { }).catch(error => {
if (error.response) { if (error.response) {
if (error.response.status === 404) { if (error.response.status === 404) {
self.error = 'server_not_found' self.error = 'server_not_found'
} } else if (error.response.status === 403) {
else if (error.response.status === 403) {
self.error = 'server_requires_auth' self.error = 'server_requires_auth'
} } else if (error.response.status === 500) {
else if (error.response.status === 500) {
self.error = 'server_error' self.error = 'server_error'
} } else {
else {
self.error = 'server_unknown_error' self.error = 'server_unknown_error'
} }
} else { } else {
self.error = 'server_unknown_error' self.error = 'server_unknown_error'
} }
self.isLoading = false; self.isLoading = false
}) })
}, },
fetchTracks (filters, path) { fetchTracks (filters, path) {
path = path || "/api/v1/tracks/" path = path || '/api/v1/tracks/'
filters.include_channels = "true" filters.include_channels = 'true'
let self = this const self = this
let url = `${this.baseUrl}${path}` const url = `${this.baseUrl}${path}`
axios.get(url, {params: filters}).then(response => { axios.get(url, { params: filters }).then(response => {
self.tracks = self.parseTracks(response.data.results) self.tracks = self.parseTracks(response.data.results)
self.isLoading = false; self.isLoading = false
}).catch(error => { }).catch(error => {
if (error.response) { if (error.response) {
if (error.response.status === 404) { if (error.response.status === 404) {
self.error = 'server_not_found' self.error = 'server_not_found'
} } else if (error.response.status === 403) {
else if (error.response.status === 403) {
self.error = 'server_requires_auth' self.error = 'server_requires_auth'
} } else if (error.response.status === 500) {
else if (error.response.status === 500) {
self.error = 'server_error' self.error = 'server_error'
} } else {
else {
self.error = 'server_unknown_error' self.error = 'server_unknown_error'
} }
} else { } else {
self.error = 'server_unknown_error' self.error = 'server_unknown_error'
} }
self.isLoading = false; self.isLoading = false
}) })
}, },
parseTracks (tracks) { parseTracks (tracks) {
let self = this const self = this
if (this.type === 'playlist') { if (this.type === 'playlist') {
tracks = tracks.map((t) => { tracks = tracks.map((t) => {
return t.track return t.track
@ -325,7 +461,7 @@ export default {
}) })
}, },
bindEvents () { bindEvents () {
let self = this const self = this
this.$refs.player.player.on('ended', () => { this.$refs.player.player.on('ended', () => {
self.next() self.next()
}) })
@ -336,17 +472,17 @@ export default {
} }
return path return path
}, },
getCover(albumCover) { getCover (albumCover) {
if (albumCover) { if (albumCover) {
return albumCover.urls.medium_square_crop return albumCover.urls.medium_square_crop
} }
}, },
getSources (uploads) { getSources (uploads) {
let self = this; const self = this
let a = document.createElement('audio') const a = document.createElement('audio')
let allowed = ['probably', 'maybe'] const allowed = ['probably', 'maybe']
let sources = uploads.filter(u => { const sources = uploads.filter(u => {
let canPlay = a.canPlayType(u.mimetype) const canPlay = a.canPlayType(u.mimetype)
return allowed.indexOf(canPlay) > -1 return allowed.indexOf(canPlay) > -1
}).map(u => { }).map(u => {
return { return {
@ -371,26 +507,6 @@ export default {
} }
return sources 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> </script>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +1,50 @@
<template> <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" <svg
viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve"> id="layer_1"
<g> 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> <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 <g>
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
<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 :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" 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 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"/> 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 </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 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 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"/> 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> />
</g>
</svg> </svg>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
fill: {type: String, default: '#222222'} fill: { type: String, default: '#222222' }
} }
} }
</script> </script>

View File

@ -1,5 +1,8 @@
<template> <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 transform="translate(34.65295 -109.48195)">
<g> <g>
<g transform="matrix(.3191 0 0 .3191 -45.91741 93.47184)"> <g transform="matrix(.3191 0 0 .3191 -45.91741 93.47184)">
@ -14,7 +17,11 @@
</g> </g>
</g> </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="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="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" /> <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> <script>
export default { export default {
props: { props: {
primary: {type: String, default: '#009fe3'}, primary: { type: String, default: '#009fe3' },
secondary: {type: String, default: 'var(--text-color)'}, secondary: { type: String, default: 'var(--text-color)' },
text: {type: String, default: 'var(--text-color)'}, text: { type: String, default: 'var(--text-color)' }
} }
} }
</script> </script>

View File

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

View File

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

View File

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

View File

@ -1,19 +1,51 @@
<template> <template>
<div v-if="type === 'both' || type === undefined" class="two ui buttons"> <div
<button class="ui left floated labeled icon button" @click.prevent="changeType('rss')"><i class="feed icon"></i> v-if="type === 'both' || type === undefined"
<translate translate-context="Content/Search/Input.Label/Noun">RSS</translate> 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> </button>
<div class="or"></div> <div class="or" />
<button class="ui right floated right labeled icon button" @click.prevent="changeType('artists')"><i class="globe icon"></i> <button
<translate translate-context="Content/Search/Input.Label/Noun">Fediverse</translate> 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> </button>
</div> </div>
<div v-else> <div v-else>
<form id="remote-search" :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit"> <form
<div v-if="errors.length > 0" role="alert" class="ui negative message"> id="remote-search"
<h3 class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></h3> :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"> <ul class="list">
<li v-for="error in errors">{{ error }}</li> <li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul> </ul>
</div> </div>
<div class="ui required field"> <div class="ui required field">
@ -21,19 +53,45 @@
{{ labels.fieldLabel }} {{ labels.fieldLabel }}
</label> </label>
<p v-if="type === 'rss'"> <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>
<p v-else-if="type === 'artists'"> <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> </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> </div>
<button v-if="showSubmit" type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0"> <button
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate> 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> </button>
</form> </form>
<div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" role="alert" class="ui warning message"> <div
<p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p> 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>
</div> </div>
</template> </template>
@ -42,20 +100,87 @@ import axios from 'axios'
export default { export default {
props: { props: {
initialId: { type: String, required: false}, initialId: { type: String, required: false, default: '' },
type: { type: String, required: false}, initialType: { type: String, required: false, default: '' },
redirect: { type: Boolean, default: true}, redirect: { type: Boolean, default: true },
showSubmit: { type: Boolean, default: true}, showSubmit: { type: Boolean, default: true },
standalone: { type: Boolean, default: true}, standalone: { type: Boolean, default: true }
}, },
data () { data () {
return { return {
type: this.initialType,
id: this.initialId, id: this.initialId,
fetch: null, fetch: null,
obj: null, obj: null,
isLoading: false, 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 () { 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: { methods: {
changeType(newType) { changeType (newType) {
this.type = newType this.type = newType
}, },
submit () { submit () {
@ -135,13 +209,13 @@ export default {
return return
} }
if (this.standalone) { if (this.standalone) {
this.$router.replace({name: "search", query: {id: this.id}}) this.$router.replace({ name: 'search', query: { id: this.id } })
} }
this.fetch = null this.fetch = null
let self = this const self = this
self.errors = [] self.errors = []
self.isLoading = true self.isLoading = true
let payload = { const payload = {
object: this.id object: this.id
} }
@ -150,7 +224,7 @@ export default {
self.fetch = response.data self.fetch = response.data
if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') { if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
self.errors.push( 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 => { }, error => {
@ -163,40 +237,27 @@ export default {
return return
} }
if (this.standalone) { 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 this.fetch = null
let self = this const self = this
self.errors = [] self.errors = []
self.isLoading = true self.isLoading = true
let payload = { const payload = {
url: this.id url: this.id
} }
axios.post('channels/rss-subscribe/', payload).then((response) => { axios.post('channels/rss-subscribe/', payload).then((response) => {
self.isLoading = false 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) self.$emit('subscribed', response.data)
if (self.redirect) { 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 => { }, error => {
self.isLoading = false self.isLoading = false
self.errors = error.backendErrors 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> <template>
<div class="ui toast-container"> <div class="ui toast-container">
<message v-for="message in $store.state.ui.messages" :message="message" :key="message.key"></message> <message
<slot></slot> v-for="message in $store.state.ui.messages"
:key="message.key"
:message="message"
/>
<slot />
</div> </div>
</template> </template>

View File

@ -1,41 +1,105 @@
<template> <template>
<modal @update:show="$emit('update:show', $event); isError = false" :show="show"> <modal
<h3 class="header"><translate translate-context="Popup/Instance/Title">Choose your instance</translate></h3> :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 class="scrolling content">
<div v-if="isError" role="alert" class="ui negative message"> <div
<h4 class="header"><translate translate-context="Popup/Instance/Error message.Title">It is not possible to connect to the given URL</translate></h4> 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"> <ul class="list">
<li><translate translate-context="Popup/Instance/Error message.List item">The server might be down</translate></li> <li>
<li><translate translate-context="Popup/Instance/Error message.List item">The given address is not a Funkwhale server</translate></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> </ul>
</div> </div>
<form class="ui form" @submit.prevent="checkAndSwitch(instanceUrl)"> <form
<p v-if="$store.state.instance.instanceUrl" class="description" translate-context="Popup/Login/Paragraph" v-translate="{url: $store.state.instance.instanceUrl, hostname: instanceHostname }"> class="ui form"
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. @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>
<p v-else> <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> </p>
<div class="field"> <div class="field">
<label for="instance-picker"><translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate></label> <label for="instance-picker"><translate translate-context="Popup/Instance/Input.Label/Noun">Instance URL</translate></label>
<div class="ui action input"> <div class="ui action input">
<input id ="instance-picker" type="text" v-model="instanceUrl" placeholder="https://funkwhale.server"> <input
<button type="submit" :class="['ui', 'icon', {loading: isLoading}, 'button']"> id="instance-picker"
<translate translate-context="*/*/Button.Label/Verb">Submit</translate> 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> </button>
</div> </div>
</div> </div>
</form> </form>
<div class="ui hidden divider"></div> <div class="ui hidden divider" />
<form class="ui form" @submit.prevent=""> <form
class="ui form"
@submit.prevent=""
>
<div class="field"> <div class="field">
<h4><translate translate-context="Popup/Instance/List.Label">Suggested choices</translate></h4> <h4>
<button v-for="url in suggestedInstances" @click="checkAndSwitch(url)" class="ui basic button">{{ url }}</button> <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> </div>
</form> </form>
</div> </div>
<div class="actions"> <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> </div>
</modal> </modal>
</template> </template>
@ -43,25 +107,52 @@
<script> <script>
import Modal from '@/components/semantic/Modal' import Modal from '@/components/semantic/Modal'
import axios from 'axios' import axios from 'axios'
import _ from "@/lodash" import _ from '@/lodash'
export default { export default {
props: ['show'],
components: { components: {
Modal, Modal
}, },
data() { props: { show: { type: Boolean, required: true } },
data () {
return { return {
instanceUrl: null, instanceUrl: null,
nodeinfo: null, nodeinfo: null,
isError: false, isError: false,
isLoading: 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: { methods: {
fetchNodeInfo () { fetchNodeInfo () {
let self = this const self = this
axios.get('instance/nodeinfo/2.0/').then(response => { axios.get('instance/nodeinfo/2.0/').then(response => {
self.nodeinfo = response.data self.nodeinfo = response.data
}) })
@ -71,7 +162,7 @@ export default {
if (!urlFetch.endsWith('/')) { if (!urlFetch.endsWith('/')) {
urlFetch = `${urlFetch}/${this.path}` urlFetch = `${urlFetch}/${this.path}`
} else { } else {
urlFetch = `${urlFetch}${this.path}` urlFetch = `${urlFetch}${this.path}`
} }
if (!urlFetch.startsWith('https://') && !urlFetch.startsWith('http://')) { if (!urlFetch.startsWith('https://') && !urlFetch.startsWith('http://')) {
urlFetch = `https://${urlFetch}` urlFetch = `https://${urlFetch}`
@ -79,14 +170,14 @@ export default {
return urlFetch return urlFetch
}, },
requestDistantNodeInfo (url) { requestDistantNodeInfo (url) {
var self = this const self = this
axios.get(this.fetchUrl(url)).then(function (response) { axios.get(this.fetchUrl(url)).then(function (response) {
self.isLoading = false self.isLoading = false
if(!url.startsWith('https://') && !url.startsWith('http://')) { if (!url.startsWith('https://') && !url.startsWith('http://')) {
url = `https://${url}` url = `https://${url}`
} }
self.switchInstance(url) self.switchInstance(url)
}).catch(function (error) { }).catch(function () {
self.isLoading = false self.isLoading = false
self.isError = true 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 // Here we disconnect from the current instance and reconnect to the new one. No check is performed
this.$emit('update:show', false) this.$emit('update:show', false)
this.isError = 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', { this.$store.commit('ui/addMessage', {
content: this.$gettextInterpolate(msg, {url: url}), content: this.$gettextInterpolate(msg, { url: url }),
date: new Date() date: new Date()
}) })
let self = this const self = this
this.$nextTick(() => { this.$nextTick(() => {
self.$store.commit('instance/instanceUrl', null) self.$store.commit('instance/instanceUrl', null)
self.$store.dispatch('instance/setUrl', url) self.$store.dispatch('instance/setUrl', url)
@ -111,34 +202,7 @@ export default {
this.isError = false // Clear error message if any this.isError = false // Clear error message if any
this.isLoading = true this.isLoading = true
this.requestDistantNodeInfo(url) 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> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,64 +1,144 @@
<template> <template>
<section role="complementary" v-if="currentTrack" class="player-wrapper ui bottom-player component-player" aria-labelledby="player-label"> <section
<h1 id="player-label" class="visually-hidden"> v-if="currentTrack"
<translate translate-context="*/*/*">Audio player and controls</translate> 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> </h1>
<div class="ui inverted segment fixed-controls" @click.prevent.stop="toggleMobilePlayer"> <div
class="ui inverted segment fixed-controls"
@click.prevent.stop="toggleMobilePlayer"
>
<div <div
:class="['ui', 'top attached', 'small', 'inverted', {'indicating': isLoadingAudio}, 'progress']"> :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> <div
class="buffer bar"
:data-percent="bufferProgress"
:style="{ 'width': bufferProgress + '%' }"
/>
<div
class="position bar"
:data-percent="progress"
:style="{ 'width': progress + '%' }"
/>
</div> </div>
<div class="controls-row"> <div class="controls-row">
<div class="controls track-controls queue-not-focused desktop-and-up"> <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 }})"> <div
<img alt="" ref="cover" v-if="currentTrack.cover && currentTrack.cover.urls.original" :src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.medium_square_crop)"> class="ui tiny image"
<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)"> @click.stop.prevent="$router.push({name: 'library.tracks.detail', params: {id: currentTrack.id }})"
<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 && 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>
<div @click.stop.prevent="" class="middle aligned content ellipsis"> <div
class="middle aligned content ellipsis"
@click.stop.prevent=""
>
<strong> <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 }} {{ currentTrack.title }}
</router-link> </router-link>
</strong> </strong>
<div class="meta"> <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> <router-link
<template v-if="currentTrack.album"> / class="discrete link"
<router-link @click.stop.prevent="" class="discrete link" :to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}">{{ currentTrack.album.title }}</router-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> </template>
</div> </div>
</div> </div>
</div> </div>
<div class="controls track-controls queue-not-focused tablet-and-below"> <div class="controls track-controls queue-not-focused tablet-and-below">
<div class="ui tiny image"> <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
<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)"> v-if="currentTrack.cover && currentTrack.cover.urls.original"
<img alt="" v-else src="../../assets/audio/default-cover.png"> 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>
<div class="middle aligned content ellipsis"> <div class="middle aligned content ellipsis">
<strong> <strong>
{{ currentTrack.title }} {{ currentTrack.title }}
</strong> </strong>
<div class="meta"> <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>
</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 <track-favorite-icon
class="control white" class="control white"
:track="currentTrack"></track-favorite-icon> :track="currentTrack"
/>
<track-playlist-icon <track-playlist-icon
class="control white" class="control white"
:track="currentTrack"></track-playlist-icon> :track="currentTrack"
/>
<button <button
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']" :class="['ui', 'really', 'basic', 'circular', 'icon', 'button', 'control']"
:aria-label="labels.addArtistContentFilter" :aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter"> :title="labels.addArtistContentFilter"
<i :class="['eye slash outline', 'basic', 'icon']"></i> @click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button> </button>
</div> </div>
<div class="player-controls controls queue-not-focused"> <div class="player-controls controls queue-not-focused">
@ -66,41 +146,48 @@
:title="labels.previous" :title="labels.previous"
:aria-label="labels.previous" :aria-label="labels.previous"
class="circular button control tablet-and-up" class="circular button control tablet-and-up"
:disabled="!hasPrevious"
@click.prevent.stop="$store.dispatch('queue/previous')" @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>
<button <button
v-if="!playing" v-if="!playing"
:title="labels.play" :title="labels.play"
:aria-label="labels.play" :aria-label="labels.play"
class="circular button control"
@click.prevent.stop="resumePlayback" @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>
<button <button
v-else v-else
:title="labels.pause" :title="labels.pause"
:aria-label="labels.pause" :aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="pausePlayback" @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>
<button <button
:title="labels.next" :title="labels.next"
:aria-label="labels.next" :aria-label="labels.next"
class="circular button control" class="circular button control"
:disabled="!hasNext"
@click.prevent.stop="$store.dispatch('queue/next')" @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> </button>
</div> </div>
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left"> <div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
<div class="timer"> <div class="timer">
<template v-if="!isLoadingAudio"> <template v-if="!isLoadingAudio">
<span class="start" @click.stop.prevent="setCurrentTime(0)">{{currentTimeFormatted}}</span> <span
| <span class="total">{{durationFormatted}}</span> class="start"
@click.stop.prevent="setCurrentTime(0)"
>{{ currentTimeFormatted }}</span>
| <span class="total">{{ durationFormatted }}</span>
</template> </template>
<template v-else> <template v-else>
00:00 | 00:00 00:00 | 00:00
@ -111,35 +198,40 @@
<div class="group"> <div class="group">
<volume-control class="expandable" /> <volume-control class="expandable" />
<button <button
class="circular control button"
v-if="looping === 0" v-if="looping === 0"
class="circular control button"
:title="labels.loopingDisabled" :title="labels.loopingDisabled"
:aria-label="labels.loopingDisabled" :aria-label="labels.loopingDisabled"
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 1)" @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>
<button <button
v-if="looping === 1"
class="looping circular control button" class="looping circular control button"
@click.prevent.stop="$store.commit('player/looping', 2)"
:title="labels.loopingSingle" :title="labels.loopingSingle"
:aria-label="labels.loopingSingle" :aria-label="labels.loopingSingle"
v-if="looping === 1" :disabled="!currentTrack"
:disabled="!currentTrack"> @click.prevent.stop="$store.commit('player/looping', 2)"
>
<i <i
class="repeat icon"> class="repeat icon"
>
<span class="ui circular tiny vibrant label">1</span> <span class="ui circular tiny vibrant label">1</span>
</i> </i>
</button> </button>
<button <button
v-if="looping === 2"
class="looping circular control button" class="looping circular control button"
:title="labels.loopingWhole" :title="labels.loopingWhole"
:aria-label="labels.loopingWhole" :aria-label="labels.loopingWhole"
v-if="looping === 2"
:disabled="!currentTrack" :disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 0)"> @click.prevent.stop="$store.commit('player/looping', 0)"
>
<i <i
class="repeat icon"> class="repeat icon"
>
<span class="ui circular tiny vibrant label">&infin;</span> <span class="ui circular tiny vibrant label">&infin;</span>
</i> </i>
</button> </button>
@ -148,55 +240,80 @@
:disabled="queue.tracks.length === 0" :disabled="queue.tracks.length === 0"
:title="labels.shuffle" :title="labels.shuffle"
:aria-label="labels.shuffle" :aria-label="labels.shuffle"
@click.prevent.stop="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> <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> </button>
</div> </div>
<div class="group"> <div class="group">
<div class="fake-dropdown"> <div class="fake-dropdown">
<button class="position circular control button desktop-and-up" @click.stop="toggleMobilePlayer" aria-expanded="true"> <button
<i class="stream icon"></i> class="position circular control button desktop-and-up"
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> 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 } %{ index } of %{ length }
</translate> </translate>
</button> </button>
<button class="position circular control button tablet-and-below" @click.stop="switchTab"> <button
<i class="stream icon"></i> class="position circular control button tablet-and-below"
<translate translate-context="Sidebar/Queue/Text" :translate-params="{index: queue.currentIndex + 1, length: queue.tracks.length}"> @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 } %{ index } of %{ length }
</translate> </translate>
</button> </button>
<button <button
class="circular control button close-control desktop-and-up"
v-if="$store.state.ui.queueFocused" v-if="$store.state.ui.queueFocused"
@click.stop="toggleMobilePlayer"> class="circular control button close-control desktop-and-up"
<i class="large down angle icon"></i> @click.stop="toggleMobilePlayer"
>
<i class="large down angle icon" />
</button> </button>
<button <button
class="circular control button desktop-and-up"
v-else v-else
@click.stop="toggleMobilePlayer"> class="circular control button desktop-and-up"
<i class="large up angle icon"></i> @click.stop="toggleMobilePlayer"
>
<i class="large up angle icon" />
</button> </button>
<button <button
class="circular control button close-control tablet-and-below"
v-if="$store.state.ui.queueFocused === 'player'" v-if="$store.state.ui.queueFocused === 'player'"
@click.stop="switchTab"> class="circular control button close-control tablet-and-below"
<i class="large up angle icon"></i> @click.stop="switchTab"
>
<i class="large up angle icon" />
</button> </button>
<button <button
class="circular control button tablet-and-below"
v-if="$store.state.ui.queueFocused === 'queue'" v-if="$store.state.ui.queueFocused === 'queue'"
@click.stop="switchTab"> class="circular control button tablet-and-below"
<i class="large down angle icon"></i> @click.stop="switchTab"
>
<i class="large down angle icon" />
</button> </button>
</div> </div>
<button <button
class="circular control button close-control tablet-and-below" class="circular control button close-control tablet-and-below"
@click.stop="$store.commit('ui/queueFocused', null)"> @click.stop="$store.commit('ui/queueFocused', null)"
<i class="x icon"></i> >
<i class="x icon" />
</button> </button>
</div> </div>
</div> </div>
@ -219,7 +336,7 @@
@keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)" @keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
@keydown.q.exact="clean" @keydown.q.exact="clean"
@keydown.e.exact="toggleMobilePlayer" @keydown.e.exact="toggleMobilePlayer"
/> />
</section> </section>
</template> </template>
@ -259,6 +376,124 @@ export default {
nextTrackPreloaded: false 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 () { mounted () {
this.$store.dispatch('player/updateProgress', 0) this.$store.dispatch('player/updateProgress', 0)
this.$store.commit('player/playing', false) this.$store.commit('player/playing', false)
@ -661,124 +896,6 @@ export default {
navigator.mediaSession.metadata = new window.MediaMetadata(metadata) 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> </script>

View File

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

View File

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

View File

@ -1,74 +1,87 @@
<template> <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 <span
role="button"
v-if="sliderVolume === 0" v-if="sliderVolume === 0"
role="button"
:title="labels.unmute" :title="labels.unmute"
:aria-label="labels.unmute" :aria-label="labels.unmute"
@click.prevent.stop="unmute"> @click.prevent.stop="unmute"
<i class="volume off icon"></i> >
<i class="volume off icon" />
</span> </span>
<span <span
role="button"
v-else-if="sliderVolume < 0.5" v-else-if="sliderVolume < 0.5"
role="button"
:title="labels.mute" :title="labels.mute"
:aria-label="labels.mute" :aria-label="labels.mute"
@click.prevent.stop="mute"> @click.prevent.stop="mute"
<i class="volume down icon"></i> >
<i class="volume down icon" />
</span> </span>
<span <span
role="button"
v-else v-else
role="button"
:title="labels.mute" :title="labels.mute"
:aria-label="labels.mute" :aria-label="labels.mute"
@click.prevent.stop="mute"> @click.prevent.stop="mute"
<i class="volume up icon"></i> >
<i class="volume up icon" />
</span> </span>
<div class="popup"> <div class="popup">
<label for="volume-slider" class="visually-hidden">{{ labels.slider }}</label> <label
for="volume-slider"
class="visually-hidden"
>{{ labels.slider }}</label>
<input <input
id="volume-slider" id="volume-slider"
v-model="sliderVolume"
type="range" type="range"
step="any" step="any"
min="0" min="0"
v-bind:max="volumeSteps" :max="volumeSteps"
v-model="sliderVolume" /> >
</div> </div>
</button> </button>
</template> </template>
<script> <script>
import { mapState, mapGetters, mapActions } from "vuex" import mapActions from 'vuex'
export default { export default {
data () { data () {
return { return {
expanded: false, expanded: false,
timeout: null, timeout: null,
volumeSteps: 100, volumeSteps: 100
} }
}, },
computed: { computed: {
sliderVolume: { sliderVolume: {
get () { get () {
return this.$store.state.player.volume * this.volumeSteps; return this.$store.state.player.volume * this.volumeSteps
}, },
set (v) { set (v) {
this.$store.commit("player/volume", v / this.volumeSteps) this.$store.commit('player/volume', v / this.volumeSteps)
} }
}, },
labels () { labels () {
return { return {
unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Unmute"), unmute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'),
mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Mute"), mute: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Mute'),
slider: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Adjust volume") slider: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Adjust volume')
} }
} }
}, },
methods: { methods: {
...mapActions({ ...mapActions({
mute: "player/mute", mute: 'player/mute',
unmute: "player/unmute", unmute: 'player/unmute',
toggleMute: "player/toggleMute", toggleMute: 'player/toggleMute'
}), }),
handleOver () { handleOver () {
if (this.timeout) { if (this.timeout) {
@ -80,7 +93,7 @@ export default {
if (this.timeout) { if (this.timeout) {
clearTimeout(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> <template>
<div class="card app-card component-album-card"> <div class="card app-card component-album-card">
<div <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}})" @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>
<div class="content"> <div class="content">
<strong> <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 }} {{ album.title }}
</router-link> </router-link>
</strong> </strong>
<div class="description"> <div class="description">
<span> <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 }} {{ album.artist.name }}
</router-link> </router-link>
</span> </span>
@ -21,8 +34,21 @@
</div> </div>
<div class="extra content"> <div class="extra content">
<span v-if="album.release_date">{{ album.release_date | moment('Y') }} · </span> <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> <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-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>
</div> </div>
</template> </template>
@ -31,17 +57,18 @@
import PlayButton from '@/components/audio/PlayButton' import PlayButton from '@/components/audio/PlayButton'
export default { export default {
props: {
album: {type: Object},
},
components: { components: {
PlayButton PlayButton
}, },
props: {
album: { type: Object, required: true }
},
computed: { computed: {
imageUrl () { imageUrl () {
if (this.album.cover && this.album.cover.urls.original) { if (this.album.cover && this.album.cover.urls.original) {
return this.$store.getters['instance/absoluteUrl'](this.album.cover.urls.medium_square_crop) return this.$store.getters['instance/absoluteUrl'](this.album.cover.urls.medium_square_crop)
} }
return null
} }
} }
} }

View File

@ -1,25 +1,54 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<h3 v-if="!!this.$slots.title" class="ui header"> <h3
<slot name="title"></slot> v-if="!!$slots.title"
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span> class="ui header"
>
<slot name="title" />
<span
v-if="showCount"
class="ui tiny circular label"
>{{ count }}</span>
</h3> </h3>
<slot></slot> <slot />
<inline-search-bar v-model="query" v-if="search" @search="albums = []; fetchData()"></inline-search-bar> <inline-search-bar
<div class="ui hidden divider"></div> v-if="search"
v-model="query"
@search="albums = []; fetchData()"
/>
<div class="ui hidden divider" />
<div class="ui app-cards cards"> <div class="ui app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer"> <div
<div class="ui loader"></div> v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div> </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> </div>
<slot v-if="!isLoading && albums.length === 0" name="empty-state"> <slot
<empty-state @refresh="fetchData" :refresh="true"></empty-state> v-if="!isLoading && albums.length === 0"
name="empty-state"
>
<empty-state
:refresh="true"
@refresh="fetchData"
/>
</slot> </slot>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider"></div> <div class="ui hidden divider" />
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> <button
<translate translate-context="*/*/Button,Label">Show more</translate> v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button> </button>
</template> </template>
</div> </div>
@ -30,16 +59,16 @@ import axios from 'axios'
import AlbumCard from '@/components/audio/album/Card' import AlbumCard from '@/components/audio/album/Card'
export default { 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: { components: {
AlbumCard 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 () { data () {
return { return {
albums: [], albums: [],
@ -48,7 +77,15 @@ export default {
errors: null, errors: null,
previousPage: null, previousPage: null,
nextPage: null, nextPage: null,
query: '', query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
} }
}, },
created () { created () {
@ -58,11 +95,11 @@ export default {
fetchData (url) { fetchData (url) {
url = url || 'albums/' url = url || 'albums/'
this.isLoading = true this.isLoading = true
let self = this const self = this
let params = {q: this.query, ...this.filters} const params = { q: this.query, ...this.filters }
params.page_size = this.limit params.page_size = this.limit
params.offset = this.offset params.offset = this.offset
axios.get(url, {params: params}).then((response) => { axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous self.previousPage = response.data.previous
self.nextPage = response.data.next self.nextPage = response.data.next
self.isLoading = false self.isLoading = false
@ -79,14 +116,6 @@ export default {
} else { } else {
this.offset = Math.max(this.offset - this.limit, 0) 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> <template>
<div class="app-card card"> <div class="app-card card">
<div <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}})" @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>
<div class="content"> <div class="content">
<strong> <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) }} {{ artist.name|truncate(30) }}
</router-link> </router-link>
</strong> </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>
<div class="extra content"> <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
<translate v-else translate-context="*/*/*" :translate-params="{count: artist.tracks_count}" :translate-n="artist.tracks_count" translate-plural="%{ count } episodes">%{ count } episode</translate> v-if="artist.content_category === 'music'"
<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-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>
</div> </div>
</template> </template>
<script> <script>
import PlayButton from '@/components/audio/PlayButton' import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List" import TagsList from '@/components/tags/List'
export default { export default {
props: ['artist'],
components: { components: {
PlayButton, PlayButton,
TagsList TagsList
}, },
props: { artist: { type: Object, required: true } },
data () { data () {
return { return {
initialAlbums: 30, initialAlbums: 30,
showAllAlbums: true, showAllAlbums: true
} }
}, },
computed: { computed: {
imageUrl () { imageUrl () {
let cover = this.cover const cover = this.cover
if (cover && cover.urls.original) { if (cover && cover.urls.original) {
return this.$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop) return this.$store.getters['instance/absoluteUrl'](cover.urls.medium_square_crop)
} }
return null
}, },
cover () { cover () {
if (this.artist.cover && this.artist.cover.urls.original) { if (this.artist.cover && this.artist.cover.urls.original) {
@ -54,7 +93,7 @@ export default {
}).filter((c) => { }).filter((c) => {
return c && c.urls.original return c && c.urls.original
})[0] })[0]
}, }
} }
} }
</script> </script>

View File

@ -1,24 +1,50 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<h3 v-if="header" class="ui header"> <h3
<slot name="title"></slot> v-if="header"
class="ui header"
>
<slot name="title" />
<span class="ui tiny circular label">{{ count }}</span> <span class="ui tiny circular label">{{ count }}</span>
</h3> </h3>
<inline-search-bar v-model="query" v-if="search" @search="objects = []; fetchData()"></inline-search-bar> <inline-search-bar
<div class="ui hidden divider"></div> v-if="search"
v-model="query"
@search="objects = []; fetchData()"
/>
<div class="ui hidden divider" />
<div class="ui five app-cards cards"> <div class="ui five app-cards cards">
<div v-if="isLoading" class="ui inverted active dimmer"> <div
<div class="ui loader"></div> v-if="isLoading"
class="ui inverted active dimmer"
>
<div class="ui loader" />
</div> </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> </div>
<slot v-if="!isLoading && objects.length === 0" name="empty-state"> <slot
<empty-state @refresh="fetchData" :refresh="true"></empty-state> v-if="!isLoading && objects.length === 0"
name="empty-state"
>
<empty-state
:refresh="true"
@refresh="fetchData"
/>
</slot> </slot>
<template v-if="nextPage"> <template v-if="nextPage">
<div class="ui hidden divider"></div> <div class="ui hidden divider" />
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> <button
<translate translate-context="*/*/Button,Label">Show more</translate> v-if="nextPage"
:class="['ui', 'basic', 'button']"
@click="fetchData(nextPage)"
>
<translate translate-context="*/*/Button,Label">
Show more
</translate>
</button> </button>
</template> </template>
</div> </div>
@ -26,17 +52,17 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import ArtistCard from "@/components/audio/artist/Card" import ArtistCard from '@/components/audio/artist/Card'
export default { export default {
props: {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
header: {type: Boolean, default: true},
search: {type: Boolean, default: false},
},
components: { 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 () { data () {
return { return {
@ -47,7 +73,15 @@ export default {
errors: null, errors: null,
previousPage: null, previousPage: null,
nextPage: null, nextPage: null,
query: '', query: ''
}
},
watch: {
offset () {
this.fetchData()
},
'$store.state.moderation.lastUpdate': function () {
this.fetchData()
} }
}, },
created () { created () {
@ -57,11 +91,11 @@ export default {
fetchData (url) { fetchData (url) {
url = url || 'artists/' url = url || 'artists/'
this.isLoading = true this.isLoading = true
let self = this const self = this
let params = {q: this.query, ...this.filters} const params = { q: this.query, ...this.filters }
params.page_size = this.limit params.page_size = this.limit
params.offset = this.offset params.offset = this.offset
axios.get(url, {params: params}).then((response) => { axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous self.previousPage = response.data.previous
self.nextPage = response.data.next self.nextPage = response.data.next
self.isLoading = false self.isLoading = false
@ -78,14 +112,6 @@ export default {
} else { } else {
this.offset = Math.max(this.offset - this.limit, 0) 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 <div
v-if="showArt" v-if="showArt"
@click.prevent.exact="activateTrack(track, index)"
class="image left floated column" class="image left floated column"
@click.prevent.exact="activateTrack(track, index)"
> >
<img <img
alt=""
class="ui artist-track mini image"
v-if=" v-if="
track.album && track.album.cover && track.album.cover.urls.original track.album && track.album.cover && track.album.cover.urls.original
" "
@ -21,10 +19,10 @@
track.album.cover.urls.medium_square_crop track.album.cover.urls.medium_square_crop
) )
" "
/>
<img
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
>
<img
v-else-if=" v-else-if="
track.cover track.cover
" "
@ -33,10 +31,10 @@
track.cover.urls.medium_square_crop track.cover.urls.medium_square_crop
) )
" "
/>
<img
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
>
<img
v-else-if=" v-else-if="
track.artist.cover track.artist.cover
" "
@ -45,19 +43,21 @@
track.artist.cover.urls.medium_square_crop track.artist.cover.urls.medium_square_crop
) )
" "
/>
<img
alt="" alt=""
class="ui artist-track mini image" class="ui artist-track mini image"
>
<img
v-else v-else
alt=""
class="ui artist-track mini image"
src="../../../assets/audio/default-cover.png" src="../../../assets/audio/default-cover.png"
/> >
</div> </div>
<div <div
tabindex=0 tabindex="0"
@click="activateTrack(track, index)"
role="button" role="button"
class="content ellipsis left floated column" class="content ellipsis left floated column"
@click="activateTrack(track, index)"
> >
<p <p
:class="[ :class="[
@ -68,24 +68,33 @@
> >
{{ track.title }} {{ track.title }}
</p> </p>
<p v-if="track.artist.content_category === 'podcast'" class="track-meta mobile"> <p
<human-date class="really discrete" :date="track.creation_date"></human-date> v-if="track.artist.content_category === 'podcast'"
class="track-meta mobile"
>
<human-date
class="really discrete"
:date="track.creation_date"
/>
<span>&#183;</span> <span>&#183;</span>
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration" :duration="track.uploads[0].duration"
></human-duration> />
</p> </p>
<p v-else class="track-meta mobile"> <p
v-else
class="track-meta mobile"
>
{{ track.artist.name }} <span>&#183;</span> {{ track.artist.name }} <span>&#183;</span>
<human-duration <human-duration
v-if="track.uploads[0] && track.uploads[0].duration" v-if="track.uploads[0] && track.uploads[0].duration"
:duration="track.uploads[0].duration" :duration="track.uploads[0].duration"
></human-duration> />
</p> </p>
</div> </div>
<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="[ :class="[
'meta', 'meta',
'right', 'right',
@ -100,12 +109,11 @@
class="tiny" class="tiny"
:border="false" :border="false"
:track="track" :track="track"
></track-favorite-icon> />
</div> </div>
<div <div
role="button" role="button"
:aria-label="actionsButtonLabel" :aria-label="actionsButtonLabel"
@click.prevent.exact="showTrackModal = !showTrackModal"
:class="[ :class="[
'modal-button', 'modal-button',
'right', 'right',
@ -114,36 +122,36 @@
'mobile', 'mobile',
{ 'with-art': showArt }, { 'with-art': showArt },
]" ]"
@click.prevent.exact="showTrackModal = !showTrackModal"
> >
<i class="ellipsis large vertical icon" /> <i class="ellipsis large vertical icon" />
</div> </div>
<track-modal <track-modal
@update:show="showTrackModal = $event;"
:show="showTrackModal" :show="showTrackModal"
:track="track" :track="track"
:index="index" :index="index"
:is-artist="isArtist" :is-artist="isArtist"
:is-album="isAlbum" :is-album="isAlbum"
></track-modal> @update:show="showTrackModal = $event;"
/>
</div> </div>
</template> </template>
<script> <script>
import PlayIndicator from "@/components/audio/track/PlayIndicator"; import { mapActions, mapGetters } from 'vuex'
import { mapActions, mapGetters } from "vuex"; import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"; import TrackModal from '@/components/audio/track/Modal'
import TrackModal from "@/components/audio/track/Modal"; import PlayOptionsMixin from '@/components/mixins/PlayOptions'
import PlayOptionsMixin from "@/components/mixins/PlayOptions"
export default { export default {
mixins: [PlayOptionsMixin],
data() { components: {
return { TrackFavoriteIcon,
showTrackModal: false, TrackModal
}
}, },
mixins: [PlayOptionsMixin],
props: { props: {
tracks: Array, tracks: { type: Array, required: true },
showAlbum: { type: Boolean, required: false, default: true }, showAlbum: { type: Boolean, required: false, default: true },
showArtist: { type: Boolean, required: false, default: true }, showArtist: { type: Boolean, required: false, default: true },
showPosition: { type: Boolean, required: false, default: false }, showPosition: { type: Boolean, required: false, default: false },
@ -155,41 +163,40 @@ export default {
showDuration: { type: Boolean, required: false, default: true }, showDuration: { type: Boolean, required: false, default: true },
index: { type: Number, required: true }, index: { type: Number, required: true },
track: { type: Object, required: true }, track: { type: Object, required: true },
isArtist: {type: Boolean, required: false, default: false}, isArtist: { type: Boolean, required: false, default: false },
isAlbum: {type: Boolean, required: false, default: false}, isAlbum: { type: Boolean, required: false, default: false }
}, },
data () {
components: { return {
PlayIndicator, showTrackModal: false
TrackFavoriteIcon, }
TrackModal,
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
currentTrack: "queue/currentTrack", currentTrack: 'queue/currentTrack'
}), }),
isPlaying() { isPlaying () {
return this.$store.state.player.playing; return this.$store.state.player.playing
}, },
actionsButtonLabel () { actionsButtonLabel () {
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions') return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
}, }
}, },
methods: { methods: {
prettyPosition(position, size) { prettyPosition (position, size) {
var s = String(position); let s = String(position)
while (s.length < (size || 2)) { while (s.length < (size || 2)) {
s = "0" + s; s = '0' + s
} }
return s; return s
}, },
...mapActions({ ...mapActions({
resumePlayback: "player/resumePlayback", resumePlayback: 'player/resumePlayback',
pausePlayback: "player/pausePlayback", pausePlayback: 'player/pausePlayback'
}), })
}, }
}; }
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,29 +1,33 @@
<template> <template>
<router-link :to="url" :title="actor.full_username"> <router-link
<template v-if="avatar"><actor-avatar :actor="actor" /><span>&nbsp;</span></template><slot>{{ repr | truncate(truncateLength) }}</slot> :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> </router-link>
</template> </template>
<script> <script>
import {hashCode, intToRGB} from '@/utils/color'
export default { export default {
props: { props: {
actor: {type: Object}, actor: { type: Object, required: true },
avatar: {type: Boolean, default: true}, avatar: { type: Boolean, default: true },
admin: {type: Boolean, default: false}, admin: { type: Boolean, default: false },
displayName: {type: Boolean, default: false}, displayName: { type: Boolean, default: false },
truncateLength: {type: Number, default: 30}, truncateLength: { type: Number, default: 30 }
}, },
computed: { computed: {
url () { url () {
if (this.admin) { 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) { 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 { } 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 () { repr () {

View File

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

View File

@ -1,36 +1,84 @@
<template> <template>
<div class="ui form"> <div class="ui form">
<div v-if="errors.length > 0" role="alert" class="ui negative message"> <div
<h4 class="header"><translate translate-context="Content/*/Error message.Title">Your attachment cannot be saved</translate></h4> 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"> <ul class="list">
<li v-for="error in errors">{{ error }}</li> <li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul> </ul>
</div> </div>
<div class="ui field"> <div class="ui field">
<span id="avatarLabel"> <span id="avatarLabel">
<slot name="label"></slot> <slot name="label" />
</span> </span>
<div class="ui stackable grid row"> <div class="ui stackable grid row">
<div class="three wide column"> <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
<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`)" /> v-if="value && value === initialValue"
<div :class="['ui', imageClass, 'static', 'large placeholder image']" v-else></div> 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>
<div class="eleven wide column"> <div class="eleven wide column">
<div class="file-input"> <div class="file-input">
<label :for="attachmentId"> <label :for="attachmentId">
<translate translate-context="*/*/*">Upload New Picture</translate> <translate translate-context="*/*/*">Upload New Picture</translate>
</label> </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>
<div class="ui very small hidden divider"></div> <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> <p>
<button class="ui basic tiny button" v-if="value" @click.stop.prevent="remove(value)"> <translate translate-context="Content/*/Paragraph">
<translate translate-context="Content/Radio/Button.Label/Verb">Remove</translate> 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> </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"> <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> </div>
</div> </div>
@ -43,8 +91,8 @@ import axios from 'axios'
export default { export default {
props: { props: {
value: {}, value: { type: String, required: true },
imageClass: {default: '', required: false} imageClass: { type: String, default: '', required: false }
}, },
data () { data () {
return { return {
@ -52,21 +100,29 @@ export default {
isLoading: false, isLoading: false,
errors: [], errors: [],
initialValue: this.value, 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: { methods: {
submit() { submit () {
this.isLoading = true this.isLoading = true
this.errors = [] this.errors = []
let self = this const self = this
this.file = this.$refs.attachment.files[0] this.file = this.$refs.attachment.files[0]
let formData = new FormData() const formData = new FormData()
formData.append("file", this.file) formData.append('file', this.file)
axios axios
.post(`attachments/`, formData, { .post('attachments/', formData, {
headers: { headers: {
"Content-Type": "multipart/form-data" 'Content-Type': 'multipart/form-data'
} }
}) })
.then( .then(
@ -81,10 +137,10 @@ export default {
} }
) )
}, },
remove(uuid) { remove (uuid) {
this.isLoading = true this.isLoading = true
this.errors = [] this.errors = []
let self = this const self = this
axios.delete(`attachments/${uuid}/`) axios.delete(`attachments/${uuid}/`)
.then( .then(
response => { response => {
@ -97,14 +153,6 @@ export default {
self.errors = error.backendErrors 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> <template>
<a role="button" class="collapse link" @click.prevent="$emit('input', !value)"> <a
<translate v-if="isCollapsed" key="1" translate-context="*/*/Button,Label">Expand</translate> role="button"
<translate v-else key="2" translate-context="*/*/Button,Label">Collapse</translate> class="collapse link"
<i :class="[{down: !isCollapsed}, {right: isCollapsed}, 'angle', 'icon']"></i> @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> </a>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
value: {type: Boolean, required: true}, value: { type: Boolean, required: true }
}, },
computed: { computed: {
isCollapsed () { isCollapsed () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,27 @@
<template> <template>
<div></div> <div />
</template> </template>
<script> <script>
import $ from 'jquery' import $ from 'jquery'
export default { export default {
props: ['message'], props: { message: { type: Object, required: true } },
mounted () { mounted () {
let self = this const self = this
let params = { const params = {
context: "#app", context: '#app',
message: this.message.content, message: this.message.content,
showProgress: 'top', showProgress: 'top',
position: "bottom right", position: 'bottom right',
progressUp: true, progressUp: true,
onRemove () { 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> </script>

View File

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

View File

@ -1,12 +1,15 @@
<template> <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> </template>
<script> <script>
export default { export default {
props: { props: {
content: {type: String, required: true}, content: { type: String, required: true }
} }
} }
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +1,90 @@
<template> <template>
<main> <main>
<div v-if="isLoading" class="ui vertical segment" v-title="labels.title"> <div
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> v-if="isLoading"
v-title="labels.title"
class="ui vertical segment"
>
<div :class="['ui', 'centered', 'active', 'inline', 'loader']" />
</div> </div>
<template v-if="object"> <template v-if="object">
<section class="ui vertical stripe segment channel-serie"> <section class="ui vertical stripe segment channel-serie">
<div class="ui stackable grid container"> <div class="ui stackable grid container">
<div class="ui seven wide column"> <div class="ui seven wide column">
<div v-if="isSerie" class="padded basic segment"> <div
<div class="ui two column grid" v-if="isSerie"> v-if="isSerie"
class="padded basic segment"
>
<div
v-if="isSerie"
class="ui two column grid"
>
<div class="column"> <div class="column">
<div class="large two-images"> <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
<img alt="" class="channel-image" v-else src="../../assets/audio/default-cover.png"> v-if="object.cover && object.cover.urls.original"
<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)"> 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"> 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> </div>
<div class="ui column right aligned"> <div class="ui column right aligned">
<tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list> <tags-list
<div class="ui small hidden divider"></div> v-if="object.tags && object.tags.length > 0"
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration> :tags="object.tags"
/>
<div class="ui small hidden divider" />
<human-duration
v-if="totalDuration > 0"
:duration="totalDuration"
/>
<template v-if="totalTracks > 0"> <template v-if="totalTracks > 0">
<div class="ui hidden very small divider"></div> <div class="ui hidden very small divider" />
<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-plural="%{ count } episodes"
:translate-n="totalTracks" :translate-n="totalTracks"
:translate-params="{count: totalTracks}"> :translate-params="{count: totalTracks}"
>
%{ count } episode %{ count } episode
</translate> </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> </template>
<div class="ui small hidden divider"></div> <div class="ui small hidden divider" />
<play-button class="vibrant" :tracks="object.tracks"></play-button> <play-button
<div class="ui hidden horizontal divider"></div> class="vibrant"
:tracks="object.tracks"
/>
<div class="ui hidden horizontal divider" />
<album-dropdown <album-dropdown
:object="object" :object="object"
:public-libraries="publicLibraries" :public-libraries="publicLibraries"
@ -41,42 +92,86 @@
:is-album="isAlbum" :is-album="isAlbum"
:is-serie="isSerie" :is-serie="isSerie"
:is-channel="isChannel" :is-channel="isChannel"
:artist="artist"></album-dropdown> :artist="artist"
/>
</div> </div>
</div> </div>
<div class="ui small hidden divider"></div> <div class="ui small hidden divider" />
<header> <header>
<h2 class="ui header" :title="object.title"> <h2
class="ui header"
:title="object.title"
>
{{ object.title }} {{ object.title }}
</h2> </h2>
<artist-label :artist="artist"></artist-label> <artist-label :artist="artist" />
</header> </header>
</div> </div>
<div v-else class="ui center aligned text padded basic segment"> <div
<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)"> v-else
<img alt="" class="channel-image" v-else src="../../assets/audio/default-cover.png"> class="ui center aligned text padded basic segment"
<div class="ui hidden divider"></div> >
<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> <header>
<h2 class="ui header" :title="object.title"> <h2
class="ui header"
:title="object.title"
>
{{ object.title }} {{ object.title }}
</h2> </h2>
<artist-label class="rounded" :artist="artist"></artist-label> <artist-label
class="rounded"
:artist="artist"
/>
</header> </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> <span v-if="object.release_date">{{ object.release_date | moment('Y') }} · </span>
<template v-if="totalTracks > 0"> <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-plural="%{ count } episodes"
:translate-n="totalTracks" :translate-n="totalTracks"
:translate-params="{count: totalTracks}"> :translate-params="{count: totalTracks}"
>
%{ count } episode %{ count } episode
</translate> </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> </template>
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration> <human-duration
<div class="ui small hidden divider"></div> v-if="totalDuration > 0"
<play-button class="vibrant" :album="object"></play-button> :duration="totalDuration"
<div class="ui horizontal hidden divider"></div> />
<div class="ui small hidden divider" />
<play-button
class="vibrant"
:album="object"
/>
<div class="ui horizontal hidden divider" />
<album-dropdown <album-dropdown
:object="object" :object="object"
:public-libraries="publicLibraries" :public-libraries="publicLibraries"
@ -85,40 +180,64 @@
:is-serie="isSerie" :is-serie="isSerie"
:is-channel="isChannel" :is-channel="isChannel"
:artist="artist" :artist="artist"
></album-dropdown> />
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local"> <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 small hidden divider" />
<div class="ui divider"></div> <div class="ui divider" />
<div class="ui small hidden divider"></div> <div class="ui small hidden divider" />
<template v-if="object.tags && object.tags.length > 0" > <template v-if="object.tags && object.tags.length > 0">
<tags-list :tags="object.tags"></tags-list> <tags-list :tags="object.tags" />
<div class="ui small hidden divider"></div> <div class="ui small hidden divider" />
</template> </template>
<rendered-description <rendered-description
v-if="object.description" v-if="object.description"
:content="object.description" :content="object.description"
:can-update="false"></rendered-description> :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"></i> <router-link
<translate translate-context="Content/*/Button.Label/Verb">Add a description</translate> 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> </router-link>
</div> </div>
</div> </div>
<template v-if="isSerie"> <template v-if="isSerie">
<div class="ui hidden divider"></div> <div class="ui hidden divider" />
<rendered-description <rendered-description
v-if="object.description" v-if="object.description"
:content="object.description" :content="object.description"
:can-update="false"></rendered-description> :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"></i> <router-link
<translate translate-context="Content/*/Button.Label/Verb">Add a description</translate> 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> </router-link>
</template> </template>
</div> </div>
<div class="nine wide column"> <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>
</div> </div>
</section> </section>
@ -127,17 +246,17 @@
</template> </template>
<script> <script>
import axios from "axios" import axios from 'axios'
import lodash from "@/lodash" import lodash from '@/lodash'
import PlayButton from "@/components/audio/PlayButton" import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List" import TagsList from '@/components/tags/List'
import ArtistLabel from '@/components/audio/ArtistLabel' import ArtistLabel from '@/components/audio/ArtistLabel'
import AlbumDropdown from './AlbumDropdown' import AlbumDropdown from './AlbumDropdown'
function groupByDisc(initial) { function groupByDisc (initial) {
function inner(acc, track) { function inner (acc, track) {
var dn = track.disc_number - initial const dn = track.disc_number - initial
if (acc[dn] == undefined) { if (acc[dn] === undefined) {
acc.push([track]) acc.push([track])
} else { } else {
acc[dn].push(track) acc[dn].push(track)
@ -148,14 +267,14 @@ function groupByDisc(initial) {
} }
export default { export default {
props: ["id"],
components: { components: {
PlayButton, PlayButton,
TagsList, TagsList,
ArtistLabel, ArtistLabel,
AlbumDropdown AlbumDropdown
}, },
data() { props: { id: { type: Number, required: true } },
data () {
return { return {
isLoading: true, isLoading: true,
object: null, object: null,
@ -163,39 +282,7 @@ export default {
discs: [], discs: [],
libraries: [], libraries: [],
page: 1, page: 1,
paginateBy: 50, 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
})
} }
}, },
computed: { computed: {
@ -212,7 +299,7 @@ export default {
return this.object.artist.content_category === 'music' return this.object.artist.content_category === 'music'
}, },
totalDuration () { totalDuration () {
let durations = [0] const durations = [0]
this.object.tracks.forEach((t) => { this.object.tracks.forEach((t) => {
if (t.uploads[0] && t.uploads[0].duration) { if (t.uploads[0] && t.uploads[0].duration) {
durations.push(t.uploads[0].duration) durations.push(t.uploads[0].duration)
@ -220,24 +307,56 @@ export default {
}) })
return lodash.sum(durations) return lodash.sum(durations)
}, },
labels() { labels () {
return { return {
title: this.$pgettext('*/*/*', 'Album'), title: this.$pgettext('*/*/*', 'Album')
} }
}, },
publicLibraries () { publicLibraries () {
return this.libraries.filter(l => { return this.libraries.filter(l => {
return l.privacy_level === 'everyone' return l.privacy_level === 'everyone'
}) })
}, }
}, },
watch: { watch: {
id() { id () {
this.fetchData() this.fetchData()
}, },
page() { page () {
this.fetchData() 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> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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