Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
Mélanie Chauvel
funkwhale
Commits
7213d932
Commit
7213d932
authored
Dec 04, 2020
by
Ciarán Ainsworth
Browse files
Merge branch 'podcast-search-capabilities' into 'develop'
Podcast search capabilities See merge request
funkwhale/funkwhale!1252
parents
6ad4ad2b
f477ba1b
Changes
11
Hide whitespace changes
Inline
Side-by-side
api/funkwhale_api/music/filters.py
View file @
7213d932
...
...
@@ -103,6 +103,7 @@ class ArtistFilter(
playable
=
filters
.
BooleanFilter
(
field_name
=
"_"
,
method
=
"filter_playable"
)
has_albums
=
filters
.
BooleanFilter
(
field_name
=
"_"
,
method
=
"filter_has_albums"
)
tag
=
TAG_FILTER
content_category
=
filters
.
CharFilter
(
"content_category"
)
scope
=
common_filters
.
ActorScopeFilter
(
actor_field
=
"tracks__uploads__library__actor"
,
distinct
=
True
,
...
...
changes/changelog.d/podcastssearch.enhancement
0 → 100644
View file @
7213d932
Added new search functions to allow users to more easily search for podcasts in the UI.
\ No newline at end of file
docs/api/parameters.yml
View file @
7213d932
...
...
@@ -118,10 +118,9 @@ Scope:
-
"
actor:alice@example.com"
-
"
domain:example.com"
Content
Type
:
name
:
"
content_
type
"
Content
Category
:
name
:
"
content_
category
"
in
:
"
query"
default
:
"
all"
description
:
|
Limits the results to those whose artist content type matches the query.
...
...
docs/swagger.yml
View file @
7213d932
...
...
@@ -407,6 +407,7 @@ paths:
-
$ref
:
"
./api/parameters.yml#/PageSize"
-
$ref
:
"
./api/parameters.yml#/Related"
-
$ref
:
"
./api/parameters.yml#/Scope"
-
$ref
:
"
./api/parameters.yml#/ContentCategory"
responses
:
200
:
content
:
...
...
@@ -505,7 +506,7 @@ paths:
-
$ref
:
"
./api/parameters.yml#/PageSize"
-
$ref
:
"
./api/parameters.yml#/Related"
-
$ref
:
"
./api/parameters.yml#/Scope"
-
$ref
:
"
./api/parameters.yml#/Content
Type
"
-
$ref
:
"
./api/parameters.yml#/Content
Category
"
responses
:
200
:
...
...
front/src/components/Sidebar.vue
View file @
7213d932
...
...
@@ -114,20 +114,22 @@
<div
class=
"ui small hidden divider"
></div>
<section
:class=
"['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']"
:aria-label=
"labels.mainMenu"
>
<nav
class=
"ui vertical large fluid inverted menu"
role=
"navigation"
:aria-label=
"labels.mainMenu"
>
<div
:class=
"[{collapsed: !exploreExpanded}, 'colla
spa
ble item']"
>
<div
:class=
"[{collapsed: !exploreExpanded}, 'colla
psi
ble item']"
>
<h2
class=
"header"
role=
"button"
@
click=
"exploreExpanded = true"
tabindex=
"0"
@
focus=
"exploreExpanded = true"
>
<translate
translate-context=
"*/*/*/Verb"
>
Explore
</translate>
<i
class=
"angle right icon"
v-if=
"!exploreExpanded"
></i>
</h2>
<div
class=
"menu"
>
<router-link
class=
"item"
:to=
"{name: 'search'}"
><i
class=
"search icon"
></i><translate
translate-context=
"Sidebar/Navigation/List item.Link/Verb"
>
Search
</translate></router-link>
<router-link
class=
"item"
:exact=
"true"
:to=
"{name: 'library.index'}"
><i
class=
"music icon"
></i><translate
translate-context=
"Sidebar/Navigation/List item.Link/Verb"
>
Browse
</translate></router-link>
<router-link
class=
"item"
:to=
"{name: 'library.podcasts.browse'}"
><i
class=
"podcast icon"
></i><translate
translate-context=
"*/*/*"
>
Podcasts
</translate></router-link>
<router-link
class=
"item"
:to=
"{name: 'library.albums.browse'}"
><i
class=
"compact disc icon"
></i><translate
translate-context=
"*/*/*"
>
Albums
</translate></router-link>
<router-link
class=
"item"
:to=
"{name: 'library.artists.browse'}"
><i
class=
"user icon"
></i><translate
translate-context=
"*/*/*"
>
Artists
</translate></router-link>
<router-link
class=
"item"
:to=
"{name: 'library.playlists.browse'}"
><i
class=
"list icon"
></i><translate
translate-context=
"*/*/*"
>
Playlists
</translate></router-link>
<router-link
class=
"item"
:to=
"{name: 'library.radios.browse'}"
><i
class=
"feed icon"
></i><translate
translate-context=
"*/*/*"
>
Radios
</translate></router-link>
</div>
</div>
<div
:class=
"[{collapsed: !myLibraryExpanded}, 'colla
spa
ble item']"
v-if=
"$store.state.auth.authenticated"
>
<div
:class=
"[{collapsed: !myLibraryExpanded}, 'colla
psi
ble item']"
v-if=
"$store.state.auth.authenticated"
>
<h3
class=
"header"
role=
"button"
@
click=
"myLibraryExpanded = true"
tabindex=
"0"
@
focus=
"myLibraryExpanded = true"
>
<translate
translate-context=
"*/*/*/Noun"
>
My Library
</translate>
<i
class=
"angle right icon"
v-if=
"!myLibraryExpanded"
></i>
...
...
@@ -225,7 +227,9 @@ export default {
},
focusedMenu
()
{
let
mapping
=
{
"
search
"
:
'
exploreExpanded
'
,
"
library.index
"
:
'
exploreExpanded
'
,
"
library.podcasts.browse
"
:
'
exploreExpanded
'
,
"
library.albums.browse
"
:
'
exploreExpanded
'
,
"
library.albums.detail
"
:
'
exploreExpanded
'
,
"
library.artists.browse
"
:
'
exploreExpanded
'
,
...
...
front/src/components/library/Artists.vue
View file @
7213d932
...
...
@@ -157,7 +157,9 @@ export default {
page
:
this
.
page
,
tag
:
this
.
tags
,
paginateBy
:
this
.
paginateBy
,
ordering
:
this
.
getOrderingAsString
()
ordering
:
this
.
getOrderingAsString
(),
content_category
:
'
music
'
,
include_channels
:
true
,
}).
toString
()
)
},
...
...
@@ -175,6 +177,7 @@ export default {
playable
:
"
true
"
,
tag
:
this
.
tags
,
include_channels
:
"
true
"
,
content_category
:
'
music
'
,
}
logger
.
default
.
debug
(
"
Fetching artists
"
)
axios
.
get
(
...
...
front/src/components/library/Podcasts.vue
0 → 100644
View file @
7213d932
<
template
>
<main
v-title=
"labels.title"
>
<section
class=
"ui vertical stripe segment"
>
<h2
class=
"ui header"
>
<translate
translate-context=
"Content/Podcasts/Title"
>
Browsing Podcasts
</translate>
</h2>
<form
:class=
"['ui',
{'loading': isLoading}, 'form']" @submit.prevent="updatePage();updateQueryString();fetchData()">
<div
class=
"fields"
>
<div
class=
"field"
>
<label
for=
"artist-search"
>
<translate
translate-context=
"Content/Search/Input.Label/Noun"
>
Podcast Title
</translate>
</label>
<div
class=
"ui action input"
>
<input
id=
"artist-search"
type=
"text"
name=
"search"
v-model=
"query"
:placeholder=
"labels.searchPlaceholder"
/>
<button
class=
"ui icon button"
type=
"submit"
:aria-label=
"$pgettext('Content/Search/Input.Label/Noun', 'Search')"
>
<i
class=
"search icon"
></i>
</button>
</div>
</div>
<div
class=
"field"
>
<label
for=
"tags-search"
><translate
translate-context=
"*/*/*/Noun"
>
Tags
</translate></label>
<tags-selector
v-model=
"tags"
></tags-selector>
</div>
<div
class=
"field"
>
<label
for=
"artist-ordering"
><translate
translate-context=
"Content/Search/Dropdown.Label/Noun"
>
Ordering
</translate></label>
<select
id=
"artist-ordering"
class=
"ui dropdown"
v-model=
"ordering"
>
<option
v-for=
"option in orderingOptions"
:value=
"option[0]"
>
{{
sharedLabels
.
filters
[
option
[
1
]]
}}
</option>
</select>
</div>
<div
class=
"field"
>
<label
for=
"artist-ordering-direction"
><translate
translate-context=
"Content/Search/Dropdown.Label/Noun"
>
Ordering direction
</translate></label>
<select
id=
"artist-ordering-direction"
class=
"ui dropdown"
v-model=
"orderingDirection"
>
<option
value=
"+"
><translate
translate-context=
"Content/Search/Dropdown"
>
Ascending
</translate></option>
<option
value=
"-"
><translate
translate-context=
"Content/Search/Dropdown"
>
Descending
</translate></option>
</select>
</div>
<div
class=
"field"
>
<label
for=
"artist-results"
><translate
translate-context=
"Content/Search/Dropdown.Label/Noun"
>
Results per page
</translate></label>
<select
id=
"artist-results"
class=
"ui dropdown"
v-model=
"paginateBy"
>
<option
:value=
"parseInt(12)"
>
12
</option>
<option
:value=
"parseInt(30)"
>
30
</option>
<option
:value=
"parseInt(50)"
>
50
</option>
</select>
</div>
</div>
</form>
<div
class=
"ui hidden divider"
></div>
<div
v-if=
"result && result.results.length > 0"
class=
"ui five app-cards cards"
>
<div
v-if=
"isLoading"
class=
"ui inverted active dimmer"
>
<div
class=
"ui loader"
></div>
</div>
<artist-card
:artist=
"artist"
v-for=
"artist in result.results"
:key=
"artist.id"
></artist-card>
</div>
<div
v-else-if=
"!isLoading"
class=
"ui placeholder segment sixteen wide column"
style=
"text-align: center; display: flex; align-items: center"
>
<div
class=
"ui icon header"
>
<i
class=
"podcast icon"
></i>
<translate
translate-context=
"Content/Artists/Placeholder"
>
No results matching your query
</translate>
</div>
<router-link
v-if=
"$store.state.auth.authenticated"
:to=
"
{name: 'content.index'}"
class="ui success button labeled icon">
<i
class=
"upload icon"
></i>
<translate
translate-context=
"Content/*/Verb"
>
Create a Channel
</translate>
</router-link>
<h1
v-if =
"$store.state.auth.authenticated"
class=
"ui with-actions header"
>
<div
class=
"actions"
>
<a
@
click.stop.prevent=
"showSubscribeModal = true"
>
<i
class=
"plus icon"
></i>
<translate
translate-context=
"Content/Profile/Button"
>
Subscribe to feed
</translate>
</a>
</div>
</h1>
</div>
<div
class=
"ui center aligned basic segment"
>
<pagination
v-if=
"result && result.count > paginateBy"
@
page-changed=
"selectPage"
:current=
"page"
:paginate-by=
"paginateBy"
:total=
"result.count"
></pagination>
</div>
</section>
<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; fetchData()"
:redirect=
"false"
></remote-search-form>
</div>
<div
class=
"actions"
>
<button
class=
"ui basic deny button"
>
<translate
translate-context=
"*/*/Button.Label/Verb"
>
Cancel
</translate>
</button>
<button
form=
"remote-search"
type=
"submit"
class=
"ui primary button"
>
<i
class=
"bookmark icon"
></i>
<translate
translate-context=
"*/*/*/Verb"
>
Subscribe
</translate>
</button>
</div>
</modal>
</main>
</
template
>
<
script
>
import
qs
from
'
qs
'
import
axios
from
"
axios
"
import
_
from
"
@/lodash
"
import
$
from
"
jquery
"
import
logger
from
"
@/logging
"
import
OrderingMixin
from
"
@/components/mixins/Ordering
"
import
PaginationMixin
from
"
@/components/mixins/Pagination
"
import
TranslationsMixin
from
"
@/components/mixins/Translations
"
import
ArtistCard
from
"
@/components/audio/artist/Card
"
import
Pagination
from
"
@/components/Pagination
"
import
TagsSelector
from
'
@/components/library/TagsSelector
'
import
Modal
from
'
@/components/semantic/Modal
'
import
RemoteSearchForm
from
"
@/components/RemoteSearchForm
"
const
FETCH_URL
=
"
artists/
"
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
:
{
ArtistCard
,
Pagination
,
TagsSelector
,
RemoteSearchForm
,
Modal
,
},
data
()
{
return
{
isLoading
:
true
,
result
:
null
,
page
:
parseInt
(
this
.
defaultPage
),
query
:
this
.
defaultQuery
,
tags
:
(
this
.
defaultTags
||
[]).
filter
((
t
)
=>
{
return
t
.
length
>
0
}),
orderingOptions
:
[[
"
creation_date
"
,
"
creation_date
"
],
[
"
name
"
,
"
name
"
]],
showSubscribeModal
:
false
,
}
},
created
()
{
this
.
fetchData
()
},
mounted
()
{
$
(
"
.ui.dropdown
"
).
dropdown
()
},
computed
:
{
labels
()
{
let
searchPlaceholder
=
this
.
$pgettext
(
'
Content/Search/Input.Placeholder
'
,
"
Search…
"
)
let
title
=
this
.
$pgettext
(
'
*/*/*/Noun
'
,
"
Podcasts
"
)
return
{
searchPlaceholder
,
title
}
}
},
methods
:
{
updateQueryString
:
function
()
{
history
.
pushState
(
{},
null
,
this
.
$route
.
path
+
'
?
'
+
new
URLSearchParams
(
{
query
:
this
.
query
,
page
:
this
.
page
,
tag
:
this
.
tags
,
paginateBy
:
this
.
paginateBy
,
ordering
:
this
.
getOrderingAsString
(),
include_channels
:
true
,
content_category
:
'
podcast
'
,
}).
toString
()
)
},
fetchData
:
function
()
{
var
self
=
this
this
.
isLoading
=
true
let
url
=
FETCH_URL
let
params
=
{
scope
:
this
.
scope
,
page
:
this
.
page
,
page_size
:
this
.
paginateBy
,
has_albums
:
this
.
excludeCompilation
,
q
:
this
.
query
,
ordering
:
this
.
getOrderingAsString
(),
playable
:
"
true
"
,
tag
:
this
.
tags
,
include_channels
:
"
true
"
,
content_category
:
'
podcast
'
,
}
logger
.
default
.
debug
(
"
Fetching artists
"
)
axios
.
get
(
url
,
{
params
:
params
,
paramsSerializer
:
function
(
params
)
{
return
qs
.
stringify
(
params
,
{
indices
:
false
})
}
}
).
then
(
response
=>
{
self
.
result
=
response
.
data
self
.
isLoading
=
false
},
error
=>
{
self
.
result
=
null
self
.
isLoading
=
false
})
},
selectPage
:
function
(
page
)
{
this
.
page
=
page
},
updatePage
()
{
this
.
page
=
this
.
defaultPage
},
},
watch
:
{
page
()
{
this
.
updateQueryString
()
this
.
fetchData
()
},
"
$store.state.moderation.lastUpdate
"
:
function
()
{
this
.
fetchData
()
},
excludeCompilation
()
{
this
.
fetchData
()
}
}
}
</
script
>
front/src/router/index.js
View file @
7213d932
...
...
@@ -637,6 +637,23 @@ export default new Router({
defaultPage
:
route
.
query
.
page
})
},
{
path
:
"
podcasts/
"
,
name
:
"
library.podcasts.browse
"
,
component
:
()
=>
import
(
/* webpackChunkName: "podcasts" */
"
@/components/library/Podcasts
"
),
props
:
route
=>
({
defaultOrdering
:
route
.
query
.
ordering
,
defaultQuery
:
route
.
query
.
query
,
defaultTags
:
Array
.
isArray
(
route
.
query
.
tag
||
[])
?
route
.
query
.
tag
:
[
route
.
query
.
tag
],
defaultPaginateBy
:
route
.
query
.
paginateBy
,
defaultPage
:
route
.
query
.
page
})
},
{
path
:
"
me/albums
"
,
name
:
"
library.albums.me
"
,
...
...
front/src/store/ui.js
View file @
7213d932
...
...
@@ -45,6 +45,11 @@ export default {
orderingDirection
:
"
-
"
,
ordering
:
"
creation_date
"
,
},
"
library.podcasts.browse
"
:
{
paginateBy
:
30
,
orderingDirection
:
"
-
"
,
ordering
:
"
creation_date
"
,
},
"
library.radios.browse
"
:
{
paginateBy
:
12
,
orderingDirection
:
"
-
"
,
...
...
front/src/style/components/_sidebar.scss
View file @
7213d932
.ui.wide.left.sidebar
{
@include
media
(
">desktop"
)
{
width
:
$desktop-sidebar-width
;
}
@include
media
(
">widedesktop"
)
{
width
:
$widedesktop-sidebar-width
;
}
@include
media
(
">desktop"
)
{
width
:
$desktop-sidebar-width
;
}
@include
media
(
">widedesktop"
)
{
width
:
$widedesktop-sidebar-width
;
}
}
.sidebar
{
.logo
{
&
.bordered.icon
{
padding
:
.5em
.41em
!
important
;
.logo
{
&
.bordered.icon
{
padding
:
.5em
.41em
!
important
;
}
path
{
fill
:
white
;
}
}
path
{
fill
:
white
;
.tab
{
flex-direction
:
column
;
}
}
.tab
{
flex-direction
:
column
;
}
}
.component-sidebar
{
.ui.search
.input
{
flex
:
1
;
.prompt
{
border-radius
:
0
;
}
}
.ui.search
.results
{
vertical-align
:
middle
;
}
.ui.search
.name
{
vertical-align
:
middle
;
}
&
.sidebar
{
overflow-y
:
visible
!
important
;
background
:
var
(
--
sidebar-background
);
z-index
:
1
;
@include
media
(
">desktop"
)
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
space-between
;
padding-bottom
:
4em
;
.ui.search
.input
{
flex
:
1
;
.prompt
{
border-radius
:
0
;
}
}
>
nav
{
flex-grow
:
1
;
overflow-y
:
auto
;
.ui.search
.results
{
vertical-align
:
middle
;
}
.ui.search
.name
{
vertical-align
:
middle
;
}
&
.sidebar
{
overflow-y
:
visible
!
important
;
background
:
var
(
--
sidebar-background
);
z-index
:
1
;
@include
media
(
">desktop"
)
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
space-between
;
padding-bottom
:
4em
;
}
>
nav
{
flex-grow
:
1
;
overflow-y
:
auto
;
}
@include
media
(
">desktop"
)
{
.menu
.item.collapse-button-wrapper
{
padding
:
0
;
}
.collapse.button
{
display
:
none
!
important
;
}
}
@include
media
(
"<=desktop"
)
{
position
:
static
!
important
;
width
:
100%
!
important
;
&
.collapsed
{
.player-wrapper
,
.search
,
.signup.segment
,
nav
.secondary
{
display
:
none
;
}
}
}
>
div
{
margin
:
0
;
background-color
:
var
(
--
sidebar-background
);
}
.menu.vertical
{
background
:
transparent
;
}
}
@include
media
(
">desktop"
)
{
.menu
.item.collapse-button-wrapper
{
padding
:
0
;
}
.collapse.button
{
display
:
none
!
important
;
}
}
@include
media
(
"<=desktop"
)
{
position
:
static
!
important
;
width
:
100%
!
important
;
&
.collapsed
{
.player-wrapper
,
.search
,
.signup.segment
,
nav
.secondary
{
display
:
none
;
}
}
.ui.vertical.menu
{
.item
.item
{
font-size
:
1em
;
>
i
.icon
{
float
:
none
;
margin
:
0
0
.5em
0
0
;
}
}
.item.active
{
border-right
:
5px
solid
var
(
--
vibrant-color
);
border-radius
:
0
!
important
;
background
:
var
(
--
sidebar-active-item-background
)
!
important
;
}
.item.collapsed
{
&
:not
(
:focus
)>
.menu
{
display
:
none
;
}
.header
{
margin-bottom
:
0
;
}
}
.collapsible.item
.header
{
cursor
:
pointer
;
}
}
>
div
{
margin
:
0
;
background-color
:
var
(
--
sidebar-background
);
.ui.secondary.menu
{
margin-left
:
0
;
margin-right
:
0
;
}
.tabs
{
flex
:
1
;
display
:
flex
;
flex-direction
:
column
;
overflow-y
:
auto
;
justify-content
:
space-between
;
@include
media
(
"<=desktop"
)
{
max-height
:
500px
;
}
}
.
menu.vertical
{
background
:
transparent
;
.
ui.tab.active
{
display
:
flex
;
}
}
.ui.vertical.menu
{
.item
.item
{
font-size
:
1em
;
>
i
.icon
{
float
:
none
;
margin
:
0
0
.5em
0
0
;
}
}
.item.active
{
border-right
:
5px
solid
var
(
--
vibrant-color
);
border-radius
:
0
!
important
;
background
:
var
(
--
sidebar-active-item-background
)
!
important
;
}
.item.collapsed
{
&
:not
(
:focus
)
>
.menu
{
display
:
none
;
}
.header
{
.tab
[
data-tab
=
"queue"
]
{
flex-direction
:
column
;
tr
{
cursor
:
pointer
;
}
td
:nth-child
(
2
)
{
width
:
55px
;
}
}
.item
.header
.angle.icon
{
float
:
right
;