Commit f477ba1b authored by Ciarán Ainsworth's avatar Ciarán Ainsworth
Browse files

Podcast search capabilities

parent 6ad4ad2b
......@@ -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,
......
Added new search functions to allow users to more easily search for podcasts in the UI.
\ No newline at end of file
......@@ -118,10 +118,9 @@ Scope:
- "actor:alice@example.com"
- "domain:example.com"
ContentType:
name: "content_type"
ContentCategory:
name: "content_category"
in: "query"
default: "all"
description: |
Limits the results to those whose artist content type matches the query.
......
......@@ -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#/ContentType"
- $ref: "./api/parameters.yml#/ContentCategory"
responses:
200:
......
......@@ -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}, 'collaspable item']">
<div :class="[{collapsed: !exploreExpanded}, 'collapsible 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}, 'collaspable item']" v-if="$store.state.auth.authenticated">
<div :class="[{collapsed: !myLibraryExpanded}, 'collapsible 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',
......
......@@ -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(
......
<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>
......@@ -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",
......
......@@ -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: "-",
......
.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;
margin: 0;
}