kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
See #170: subscriptions management UI
rodzic
926a5cfc83
commit
be067b9ee3
|
@ -384,12 +384,9 @@ def get_channel_from_rss_url(url, raise_exception=False):
|
||||||
library=channel.library,
|
library=channel.library,
|
||||||
delete_existing=True,
|
delete_existing=True,
|
||||||
)
|
)
|
||||||
latest_upload_date = max([upload.creation_date for upload in uploads])
|
if uploads:
|
||||||
if (
|
latest_track_date = max([upload.track.creation_date for upload in uploads])
|
||||||
not channel.artist.modification_date
|
common_utils.update_modification_date(channel.artist, date=latest_track_date)
|
||||||
or channel.artist.modification_date < latest_upload_date
|
|
||||||
):
|
|
||||||
common_utils.update_modification_date(channel.artist)
|
|
||||||
return channel, uploads
|
return channel, uploads
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -410,15 +410,15 @@ def get_audio_mimetype(mt):
|
||||||
return aliases.get(mt, mt)
|
return aliases.get(mt, mt)
|
||||||
|
|
||||||
|
|
||||||
def update_modification_date(obj, field="modification_date"):
|
def update_modification_date(obj, field="modification_date", date=None):
|
||||||
IGNORE_DELAY = 60
|
IGNORE_DELAY = 60
|
||||||
current_value = getattr(obj, field)
|
current_value = getattr(obj, field)
|
||||||
now = timezone.now()
|
date = date or timezone.now()
|
||||||
ignore = current_value is not None and current_value < now - datetime.timedelta(
|
ignore = current_value is not None and current_value < date - datetime.timedelta(
|
||||||
seconds=IGNORE_DELAY
|
seconds=IGNORE_DELAY
|
||||||
)
|
)
|
||||||
if ignore:
|
if ignore:
|
||||||
setattr(obj, field, now)
|
setattr(obj, field, date)
|
||||||
obj.__class__.objects.filter(pk=obj.pk).update(**{field: now})
|
obj.__class__.objects.filter(pk=obj.pk).update(**{field: date})
|
||||||
|
|
||||||
return now
|
return date
|
||||||
|
|
|
@ -791,7 +791,7 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
|
||||||
<itunes:subtitle>Subtitle</itunes:subtitle>
|
<itunes:subtitle>Subtitle</itunes:subtitle>
|
||||||
<itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
|
<itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
|
||||||
<guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
|
<guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-9101-2746218c4f32]]></guid>
|
||||||
<pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
|
<pubDate>Wed, 11 Mar 2020 18:00:00 GMT</pubDate>
|
||||||
<itunes:duration>00:22:37</itunes:duration>
|
<itunes:duration>00:22:37</itunes:duration>
|
||||||
<itunes:keywords>pop rock</itunes:keywords>
|
<itunes:keywords>pop rock</itunes:keywords>
|
||||||
<itunes:season>2</itunes:season>
|
<itunes:season>2</itunes:season>
|
||||||
|
@ -806,7 +806,7 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
|
||||||
<itunes:subtitle>Subtitle</itunes:subtitle>
|
<itunes:subtitle>Subtitle</itunes:subtitle>
|
||||||
<itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
|
<itunes:summary><![CDATA[<p>Html content</p>]]></itunes:summary>
|
||||||
<guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-910e-2746218c4f32]]></guid>
|
<guid isPermaLink="false"><![CDATA[16f66fff-41ae-4a1c-910e-2746218c4f32]]></guid>
|
||||||
<pubDate>Wed, 11 Mar 2020 16:00:00 GMT</pubDate>
|
<pubDate>Wed, 11 Mar 2020 17:00:00 GMT</pubDate>
|
||||||
<itunes:duration>00:22:37</itunes:duration>
|
<itunes:duration>00:22:37</itunes:duration>
|
||||||
<itunes:keywords>pop rock</itunes:keywords>
|
<itunes:keywords>pop rock</itunes:keywords>
|
||||||
<itunes:season>2</itunes:season>
|
<itunes:season>2</itunes:season>
|
||||||
|
@ -865,7 +865,9 @@ def test_get_channel_from_rss_url(db, r_mock, mocker):
|
||||||
library=channel.library,
|
library=channel.library,
|
||||||
delete_existing=True,
|
delete_existing=True,
|
||||||
)
|
)
|
||||||
update_modification_date.assert_called_once_with(channel.artist)
|
update_modification_date.assert_called_once_with(
|
||||||
|
channel.artist, date=uploads[0].track.creation_date
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_channel_from_rss_honor_mrf_inbox_before_http(
|
def test_get_channel_from_rss_honor_mrf_inbox_before_http(
|
||||||
|
|
|
@ -0,0 +1,188 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<form id="remote-search" :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
|
||||||
|
<div v-if="errors.length > 0" class="ui negative message">
|
||||||
|
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></div>
|
||||||
|
<ul class="list">
|
||||||
|
<li v-for="error in errors">{{ error }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="ui required field">
|
||||||
|
<label for="object-id">
|
||||||
|
{{ labels.fieldLabel }}
|
||||||
|
</label>
|
||||||
|
<p v-if="type === 'rss'">
|
||||||
|
<translate translate-context="Content/Fetch/Paragraph">Paste here the RSS url or the fediverse address to subscribe to its feed.</translate>
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
<translate translate-context="Content/Fetch/Paragraph">Use this form to retrieve an object hosted somewhere else in the fediverse.</translate>
|
||||||
|
</p>
|
||||||
|
<input type="text" name="object-id" id="object-id" :placeholder="labels.fieldPlaceholder" v-model="id" required>
|
||||||
|
</div>
|
||||||
|
<button v-if="showSubmit" type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0">
|
||||||
|
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" class="ui warning message">
|
||||||
|
<p><translate translate-context="Content/*/Error message.Title">This kind of object isn't supported yet</translate></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
initialId: { type: String, required: false},
|
||||||
|
type: { type: String, required: false},
|
||||||
|
redirect: { type: Boolean, default: true},
|
||||||
|
showSubmit: { type: Boolean, default: true},
|
||||||
|
standalone: { type: Boolean, default: true},
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
id: this.initialId,
|
||||||
|
fetch: null,
|
||||||
|
obj: null,
|
||||||
|
isLoading: false,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
if (this.id) {
|
||||||
|
if (this.type === 'rss') {
|
||||||
|
this.rssSubscribe()
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this.createFetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels() {
|
||||||
|
let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
|
||||||
|
let fieldLabel = this.$pgettext('Head/Fetch/Field.Label', "URL or @username")
|
||||||
|
let fieldPlaceholder = ""
|
||||||
|
if (this.type === "rss") {
|
||||||
|
title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
|
||||||
|
fieldLabel = this.$pgettext('*/*/*', "Channel location")
|
||||||
|
fieldLabel = this.$pgettext('*/*/*', "Channel location")
|
||||||
|
fieldPlaceholder = this.$pgettext('Head/Fetch/Field.Placeholder', "@channel@pod.example or https://website.example/rss.xml")
|
||||||
|
}
|
||||||
|
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}}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
submit () {
|
||||||
|
if (this.type === 'rss') {
|
||||||
|
return this.rssSubscribe()
|
||||||
|
} else {
|
||||||
|
return this.createFetch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
createFetch () {
|
||||||
|
if (!this.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.standalone) {
|
||||||
|
this.$router.replace({name: "search", query: {id: this.id}})
|
||||||
|
}
|
||||||
|
this.fetch = null
|
||||||
|
let self = this
|
||||||
|
self.errors = []
|
||||||
|
self.isLoading = true
|
||||||
|
let payload = {
|
||||||
|
object: this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post('federation/fetches/', payload).then((response) => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.fetch = response.data
|
||||||
|
if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
|
||||||
|
self.errors.push(
|
||||||
|
self.$pgettext("Content/*/Error message.Title", "This object cannot be retrieved")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rssSubscribe () {
|
||||||
|
if (!this.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.standalone) {
|
||||||
|
console.log('HELLO')
|
||||||
|
this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
|
||||||
|
}
|
||||||
|
this.fetch = null
|
||||||
|
let self = this
|
||||||
|
self.errors = []
|
||||||
|
self.isLoading = true
|
||||||
|
let payload = {
|
||||||
|
url: this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.post('channels/rss-subscribe/', payload).then((response) => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
|
||||||
|
self.$emit('subscribed', response.data)
|
||||||
|
if (self.redirect) {
|
||||||
|
self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
|
||||||
|
}
|
||||||
|
|
||||||
|
}, error => {
|
||||||
|
self.isLoading = false
|
||||||
|
self.errors = error.backendErrors
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
initialId (v) {
|
||||||
|
this.id = v
|
||||||
|
this.createFetch()
|
||||||
|
},
|
||||||
|
redirectRoute (v) {
|
||||||
|
if (v && this.redirect) {
|
||||||
|
this.$router.push(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -134,6 +134,9 @@
|
||||||
<router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link>
|
<router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<router-link class="header item" :to="{name: 'subscriptions'}" v-if="$store.state.auth.authenticated">
|
||||||
|
<translate translate-context="*/*/*/Noun">Subscriptions</translate>
|
||||||
|
</router-link>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<translate translate-context="Footer/About/List item.Link">More</translate>
|
<translate translate-context="Footer/About/List item.Link">More</translate>
|
||||||
|
|
|
@ -12,16 +12,24 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</strong>
|
</strong>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
|
<translate class="meta ellipsis" translate-context="Content/Channel/Paragraph"
|
||||||
|
translate-plural="%{ count } episodes"
|
||||||
|
:translate-n="object.artist.tracks_count"
|
||||||
|
:translate-params="{count: object.artist.tracks_count}">
|
||||||
|
%{ count } episode
|
||||||
|
</translate>
|
||||||
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
|
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.artist.tags"></tags-list>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<translate translate-context="Content/Channel/Paragraph"
|
<time
|
||||||
translate-plural="%{ count } episodes"
|
v-translate
|
||||||
:translate-n="object.artist.tracks_count"
|
class="meta ellipsis"
|
||||||
:translate-params="{count: object.artist.tracks_count}">
|
:datetime="object.artist.modification_date"
|
||||||
%{ count } episode
|
:title="updatedTitle">
|
||||||
</translate>
|
{{ object.artist.modification_date | fromNow }}
|
||||||
|
</time>
|
||||||
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
|
<play-button class="right floated basic icon" :dropdown-only="true" :is-playable="true" :dropdown-icon-classes="['ellipsis', 'horizontal', 'large', 'grey']" :artist="object.artist"></play-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -31,6 +39,8 @@
|
||||||
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'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
object: {type: Object},
|
object: {type: Object},
|
||||||
|
@ -58,6 +68,11 @@ export default {
|
||||||
} else {
|
} else {
|
||||||
return this.object.uuid
|
return this.object.uuid
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updatedTitle () {
|
||||||
|
let d = momentFormat(this.object.artist.modification_date)
|
||||||
|
let message = this.$pgettext('*/*/*', 'Updated on %{ date }')
|
||||||
|
return this.$gettextInterpolate(message, {date: d})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
<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="labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
|
<input id="search-query" name="search-query" type="text" :placeholder="placeholder || labels.searchPlaceholder" :value="value" @input="$emit('input', $event.target.value)">
|
||||||
<i v-if="isClearable" class="x link icon" :title="labels.clear" @click="$emit('input', ''); $emit('search', value)"></i>
|
<i v-if="isClearable" class="x link icon" :title="labels.clear" @click.stop.prevent="$emit('input', ''); $emit('search', value)"></i>
|
||||||
<button type="submit" class="ui icon basic button">
|
<button type="submit" class="ui icon basic button">
|
||||||
<i class="search icon"></i>
|
<i class="search icon"></i>
|
||||||
</button>
|
</button>
|
||||||
|
@ -15,7 +15,8 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: {type: String, required: true}
|
value: {type: String, required: true},
|
||||||
|
placeholder: {type: String, required: false},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
labels () {
|
labels () {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div :class="['ui', {'active': show}, {'overlay fullscreen': ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
|
<div :class="['ui', {'active': show}, {'overlay fullscreen': fullscreen && ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal']">
|
||||||
<i class="close inside icon"></i>
|
<i class="close inside icon"></i>
|
||||||
<slot v-if="show">
|
<slot v-if="show">
|
||||||
|
|
||||||
|
@ -12,7 +12,8 @@ import $ from 'jquery'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
show: {type: Boolean, required: true}
|
show: {type: Boolean, required: true},
|
||||||
|
fullscreen: {type: Boolean, default: true},
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -38,12 +38,38 @@ export function ago (date, locale) {
|
||||||
lastDay: 'L',
|
lastDay: 'L',
|
||||||
lastWeek: 'L',
|
lastWeek: 'L',
|
||||||
sameElse: 'L'
|
sameElse: 'L'
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.filter('ago', ago)
|
Vue.filter('ago', ago)
|
||||||
|
|
||||||
|
export function fromNow (date, locale) {
|
||||||
|
locale = 'en'
|
||||||
|
moment.locale('en', {
|
||||||
|
relativeTime: {
|
||||||
|
future: 'in %s',
|
||||||
|
past: '%s ago',
|
||||||
|
s: 'seconds',
|
||||||
|
ss: '%ss',
|
||||||
|
m: 'a minute',
|
||||||
|
mm: '%dm',
|
||||||
|
h: 'an hour',
|
||||||
|
hh: '%dh',
|
||||||
|
d: 'a day',
|
||||||
|
dd: '%dd',
|
||||||
|
M: 'a month',
|
||||||
|
MM: '%dM',
|
||||||
|
y: 'a year',
|
||||||
|
yy: '%dY'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const m = moment(date)
|
||||||
|
m.locale(locale)
|
||||||
|
return m.fromNow(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.filter('fromNow', fromNow)
|
||||||
|
|
||||||
export function secondsToObject (seconds) {
|
export function secondsToObject (seconds) {
|
||||||
let m = moment.duration(seconds, 'seconds')
|
let m = moment.duration(seconds, 'seconds')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -906,6 +906,19 @@ export default new Router({
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/subscriptions",
|
||||||
|
name: "subscriptions",
|
||||||
|
props: route => {
|
||||||
|
return {
|
||||||
|
defaultQuery: route.query.q
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: () =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "channels-auth" */ "@/views/channels/SubscriptionsList"
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*/index.html",
|
path: "*/index.html",
|
||||||
redirect: "/"
|
redirect: "/"
|
||||||
|
|
|
@ -177,6 +177,9 @@ html {
|
||||||
.ui.stripe.segment,
|
.ui.stripe.segment,
|
||||||
#footer {
|
#footer {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
|
&.ui.container {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@include media(">tablet") {
|
@include media(">tablet") {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
}
|
}
|
||||||
|
@ -372,9 +375,6 @@ input + .help {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-list {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandable {
|
.expandable {
|
||||||
&:not(.expanded) {
|
&:not(.expanded) {
|
||||||
|
@ -444,9 +444,9 @@ input + .help {
|
||||||
}
|
}
|
||||||
.ui.cards.app-cards {
|
.ui.cards.app-cards {
|
||||||
$card-width: 14em;
|
$card-width: 14em;
|
||||||
$card-height: 22em;
|
$card-height: 23em;
|
||||||
$small-card-width: 11em;
|
$small-card-width: 11em;
|
||||||
$small-card-height: 19em;
|
$small-card-height: 20em;
|
||||||
.app-card {
|
.app-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: $small-card-width;
|
width: $small-card-width;
|
||||||
|
@ -619,9 +619,11 @@ input + .help {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.header.with-actions {
|
.header.with-actions {
|
||||||
display: flex;
|
@include media(">tablet") {
|
||||||
justify-content: space-between;
|
display: flex;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 0.6em;
|
font-size: 0.6em;
|
||||||
|
@ -662,5 +664,27 @@ input + .help {
|
||||||
.ui.header .content {
|
.ui.header .content {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.with-image.item {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
height: 3em;
|
||||||
|
img.image {
|
||||||
|
width: 3em;
|
||||||
|
height: 3em;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
.icon.image {
|
||||||
|
width: 3em;
|
||||||
|
margin-right: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
@import "./themes/_light.scss";
|
@import "./themes/_light.scss";
|
||||||
@import "./themes/_dark.scss";
|
@import "./themes/_dark.scss";
|
||||||
|
|
|
@ -2,178 +2,34 @@
|
||||||
<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 small text container">
|
<div class="ui small text container">
|
||||||
<form :class="['ui', {loading: isLoading}, 'form']" @submit.stop.prevent="submit">
|
<h2>{{ labels.title }}</h2>
|
||||||
<h2>{{ labels.title }}</h2>
|
<remote-search-form :initial-id="initialId" :type="type"></remote-search-form>
|
||||||
<p v-if="type === 'rss'">
|
|
||||||
<translate translate-context="Content/Fetch/Paragraph">Use this form to subscribe to a podcast using its RSS feed.</translate>
|
|
||||||
</p>
|
|
||||||
<p v-else>
|
|
||||||
<translate translate-context="Content/Fetch/Paragraph">Use this form to retrieve an object hosted somewhere else in the fediverse.</translate>
|
|
||||||
</p>
|
|
||||||
<div v-if="errors.length > 0" class="ui negative message">
|
|
||||||
<div class="header"><translate translate-context="Content/*/Error message.Title">Error while fetching object</translate></div>
|
|
||||||
<ul class="list">
|
|
||||||
<li v-for="error in errors">{{ error }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="ui required field">
|
|
||||||
<label for="object-id">
|
|
||||||
{{ labels.fieldLabel }}
|
|
||||||
</label>
|
|
||||||
<input type="text" name="object-id" id="object-id" v-model="id" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" :class="['ui', 'primary', {loading: isLoading}, 'button']" :disabled="isLoading || !id || id.length === 0">
|
|
||||||
<translate translate-context="Content/Search/Input.Label/Noun">Search</translate>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div v-if="!isLoading && fetch && fetch.status === 'finished' && !redirectRoute" 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>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import axios from 'axios'
|
import RemoteSearchForm from '@/components/RemoteSearchForm'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
initialId: { type: String, required: false},
|
initialId: { type: String, required: false},
|
||||||
type: { type: String, required: false},
|
type: { type: String, required: false},
|
||||||
},
|
},
|
||||||
components: {},
|
components: {
|
||||||
data () {
|
RemoteSearchForm,
|
||||||
return {
|
|
||||||
id: this.initialId,
|
|
||||||
fetch: null,
|
|
||||||
obj: null,
|
|
||||||
isLoading: false,
|
|
||||||
errors: [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
if (this.id) {
|
|
||||||
if (this.type === 'rss') {
|
|
||||||
this.rssSubscribe()
|
|
||||||
|
|
||||||
} else {
|
|
||||||
this.createFetch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
labels() {
|
labels() {
|
||||||
let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
|
let title = this.$pgettext('Head/Fetch/Title', "Search a remote object")
|
||||||
let fieldLabel = this.$pgettext('Head/Fetch/Field.Placeholder', "URL or @username")
|
|
||||||
if (this.type === "rss") {
|
if (this.type === "rss") {
|
||||||
title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
|
title = this.$pgettext('Head/Fetch/Title', "Subscribe to a podcast RSS feed")
|
||||||
fieldLabel = this.$pgettext('*/*/*', "RSS Feed URL")
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
fieldLabel
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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}}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
submit () {
|
|
||||||
if (this.type === 'rss') {
|
|
||||||
return this.rssSubscribe()
|
|
||||||
} else {
|
|
||||||
return this.createFetch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
createFetch () {
|
|
||||||
if (!this.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$router.replace({name: "search", query: {id: this.id}})
|
|
||||||
this.fetch = null
|
|
||||||
let self = this
|
|
||||||
self.errors = []
|
|
||||||
self.isLoading = true
|
|
||||||
let payload = {
|
|
||||||
object: this.id
|
|
||||||
}
|
|
||||||
|
|
||||||
axios.post('federation/fetches/', payload).then((response) => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.fetch = response.data
|
|
||||||
if (self.fetch.status === 'errored' || self.fetch.status === 'skipped') {
|
|
||||||
self.errors.push(
|
|
||||||
self.$pgettext("Content/*/Error message.Title", "This object cannot be retrieved")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, error => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = error.backendErrors
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rssSubscribe () {
|
|
||||||
if (!this.id) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$router.replace({name: "search", query: {id: this.id, type: 'rss'}})
|
|
||||||
this.fetch = null
|
|
||||||
let self = this
|
|
||||||
self.errors = []
|
|
||||||
self.isLoading = true
|
|
||||||
let payload = {
|
|
||||||
url: this.id
|
|
||||||
}
|
|
||||||
|
|
||||||
axios.post('channels/rss-subscribe/', payload).then((response) => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.$store.commit('channels/subscriptions', {uuid: response.data.channel.uuid, value: true})
|
|
||||||
self.$router.push({name: 'channels.detail', params: {id: response.data.channel.uuid}})
|
|
||||||
|
|
||||||
}, error => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = error.backendErrors
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
initialId (v) {
|
|
||||||
this.id = v
|
|
||||||
this.createFetch()
|
|
||||||
},
|
|
||||||
redirectRoute (v) {
|
|
||||||
if (v) {
|
|
||||||
this.$router.push(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
<template>
|
||||||
|
<main class="main pusher" v-title="labels.title">
|
||||||
|
<section class="ui head vertical stripe segment container">
|
||||||
|
<h1 class="ui with-actions header">
|
||||||
|
{{ labels.title }}
|
||||||
|
<div class="actions">
|
||||||
|
<a @click.stop.prevent="showSubscribeModal = true">
|
||||||
|
<i class="plus icon"></i>
|
||||||
|
<translate translate-context="Content/Profile/Button">Add new</translate>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</h1>
|
||||||
|
<modal class="tiny" :show.sync="showSubscribeModal" :fullscreen="false">
|
||||||
|
<h2 class="header">
|
||||||
|
<translate translate-context="*/*/*/Noun">Subscription</translate>
|
||||||
|
</h2>
|
||||||
|
<div class="scrolling content" ref="modalContent">
|
||||||
|
<remote-search-form
|
||||||
|
type="rss"
|
||||||
|
:show-submit="false"
|
||||||
|
:standalone="false"
|
||||||
|
@subscribed="showSubscribeModal = false; reloadWidget()"
|
||||||
|
:redirect="false"></remote-search-form>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="ui basic deny button">
|
||||||
|
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||||
|
</div>
|
||||||
|
<button form="remote-search" type="submit" class="ui primary button">
|
||||||
|
<i class="bookmark icon"></i>
|
||||||
|
<translate translate-context="*/*/*/Verb">Subscribe</translate>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</modal>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<inline-search-bar v-model="query" @search="reloadWidget" :placeholder="labels.searchPlaceholder"></inline-search-bar>
|
||||||
|
<channels-widget
|
||||||
|
:key="widgetKey"
|
||||||
|
:limit="50"
|
||||||
|
:show-modification-date="true"
|
||||||
|
:filters="{q: query, subscribed: 'true', ordering: '-modification_date'}"></channels-widget>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from "axios"
|
||||||
|
import Modal from '@/components/semantic/Modal'
|
||||||
|
|
||||||
|
import ChannelsWidget from "@/components/audio/ChannelsWidget"
|
||||||
|
import RemoteSearchForm from "@/components/RemoteSearchForm"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: ["defaultQuery"],
|
||||||
|
components: {
|
||||||
|
ChannelsWidget,
|
||||||
|
RemoteSearchForm,
|
||||||
|
Modal,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
query: this.defaultQuery || '',
|
||||||
|
channels: [],
|
||||||
|
count: 0,
|
||||||
|
isLoading: false,
|
||||||
|
errors: null,
|
||||||
|
previousPage: null,
|
||||||
|
nextPage: null,
|
||||||
|
widgetKey: String(new Date()),
|
||||||
|
showSubscribeModal: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
labels () {
|
||||||
|
return {
|
||||||
|
title: this.$pgettext("Content/Subscriptions/Header", "Subscribed Channels"),
|
||||||
|
searchPlaceholder: this.$pgettext("Content/Subscriptions/Form.Placeholder", "Filter by name…"),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData() {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
axios.get('channels/', {params: {subscribed: "true", q: this.query}}).then(response => {
|
||||||
|
self.previousPage = response.data.previous
|
||||||
|
self.nextPage = response.data.next
|
||||||
|
self.isLoading = false
|
||||||
|
self.channels = [...self.channels, ...response.data.results]
|
||||||
|
self.count = response.data.count
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reloadWidget () {
|
||||||
|
this.widgetKey = String(new Date())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
Ładowanie…
Reference in New Issue