add playlist subscribed filter, align filter UIUX

This commit is contained in:
Simon 2025-08-14 17:13:15 +07:00
parent 41c9415b3f
commit d9a3d7f12f
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
13 changed files with 119 additions and 86 deletions

View File

@ -64,7 +64,9 @@ class ChannelListSerializer(serializers.Serializer):
class ChannelListQuerySerializer(serializers.Serializer):
"""serialize list query"""
filter = serializers.ChoiceField(choices=["subscribed"], required=False)
filter = serializers.ChoiceField(
choices=["subscribed", "unsubscribed"], required=False
)
page = serializers.IntegerField(required=False)

View File

@ -51,8 +51,11 @@ class ChannelApiListView(ApiBaseView):
must_list = []
query_filter = validated_data.get("filter")
if query_filter:
must_list.append({"term": {"channel_subscribed": {"value": True}}})
if query_filter is not None:
channel_subscribed = query_filter == "subscribed"
must_list.append(
{"term": {"channel_subscribed": {"value": channel_subscribed}}}
)
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)

View File

@ -46,7 +46,7 @@ class PlaylistListQuerySerializer(serializers.Serializer):
"""serialize playlist list query params"""
channel = serializers.CharField(required=False)
subscribed = serializers.BooleanField(required=False)
subscribed = serializers.BooleanField(required=False, allow_null=True)
type = serializers.ChoiceField(
choices=["regular", "custom"], required=False
)

View File

@ -26,7 +26,7 @@ class QueryBuilder:
must_list.append({"match": {"playlist_channel_id": channel}})
subscribed = self.request_params.get("subscribed")
if subscribed:
if subscribed is not None:
must_list.append({"match": {"playlist_subscribed": subscribed}})
playlist_type = self.request_params.get("type")

View File

@ -44,7 +44,8 @@ class UserMeConfigSerializer(serializers.Serializer):
hide_watched = serializers.BooleanField(allow_null=True)
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
show_ignored_only = serializers.BooleanField()
show_subed_only = serializers.BooleanField()
show_subed_only = serializers.BooleanField(allow_null=True)
show_subed_only_playlists = serializers.BooleanField(allow_null=True)
show_help_text = serializers.BooleanField()

View File

@ -25,7 +25,8 @@ class UserConfigType(TypedDict, total=False):
hide_watched: bool | None
file_size_unit: str
show_ignored_only: bool
show_subed_only: bool
show_subed_only: bool | None
show_subed_only_playlists: bool | None
show_help_text: bool
@ -49,7 +50,8 @@ class UserConfig:
hide_watched=False,
file_size_unit="binary",
show_ignored_only=False,
show_subed_only=False,
show_subed_only=None,
show_subed_only_playlists=None,
show_help_text=True,
)

View File

@ -36,7 +36,8 @@ export type UserConfigType = {
hide_watched: boolean | null;
file_size_unit: 'binary' | 'metric';
show_ignored_only: boolean;
show_subed_only: boolean;
show_subed_only: boolean | null;
show_subed_only_playlists: boolean | null;
show_help_text: boolean;
};

View File

@ -9,11 +9,13 @@ export type ChannelsListResponse = {
config?: ConfigType;
};
const loadChannelList = async (page: number, showSubscribed: boolean) => {
const loadChannelList = async (page: number, showSubscribed: boolean | null) => {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
if (showSubscribed) searchParams.append('filter', 'subscribed');
if (showSubscribed !== null) {
searchParams.append('filter', showSubscribed ? 'subscribed' : 'unsubscribed');
}
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

View File

@ -12,7 +12,7 @@ type PlaylistCategoryType = 'regular' | 'custom';
type LoadPlaylistListProps = {
channel?: string;
page?: number | undefined;
subscribed?: boolean;
subscribed?: boolean | null;
type?: PlaylistCategoryType;
};
@ -21,7 +21,8 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis
if (channel) searchParams.append('channel', channel);
if (page) searchParams.append('page', page.toString());
if (subscribed) searchParams.append('subscribed', subscribed.toString());
if (subscribed !== undefined && subscribed !== null)
searchParams.append('subscribed', subscribed.toString());
if (type) searchParams.append('type', type);
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

View File

@ -7,6 +7,7 @@ import ScrollToTopOnNavigate from '../components/ScrollToTop';
import loadPlaylistList, { PlaylistsResponseType } from '../api/loader/loadPlaylistList';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import iconFilter from '/img/icon-filter.svg';
import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
@ -18,6 +19,7 @@ const ChannelPlaylist = () => {
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
const [showFilterItems, setShowFilterItems] = useState(false);
const [playlistsResponse, setPlaylistsResponse] =
useState<ApiResponseType<PlaylistsResponseType>>();
@ -28,7 +30,7 @@ const ChannelPlaylist = () => {
const pagination = playlistsResponseData?.paginate;
const viewStyle = userConfig.view_style_playlist;
const showSubedOnly = userConfig.show_subed_only;
const showSubedOnly = userConfig.show_subed_only_playlists;
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
@ -58,30 +60,35 @@ const ChannelPlaylist = () => {
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
handleUserConfigUpdate({ show_subed_only: !showSubedOnly });
setRefreshPlaylists(true);
}}
type="checkbox"
/>
{!showSubedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only_playlists === null
? ''
: userConfig.show_subed_only_playlists.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only_playlists:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefreshPlaylists(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

@ -3,6 +3,7 @@ import loadChannelList, { ChannelsListResponse } from '../api/loader/loadChannel
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import iconAdd from '/img/icon-add.svg';
import iconFilter from '/img/icon-filter.svg';
import { useEffect, useState } from 'react';
import Pagination from '../components/Pagination';
import { OutletContextType } from './Base';
@ -52,6 +53,7 @@ const Channels = () => {
useState<ApiResponseType<ChannelsListResponse>>();
const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(true);
const [showFilterItems, setShowFilterItems] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState('');
@ -144,31 +146,34 @@ const Channels = () => {
/>
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
id="show_subed_only"
onChange={async () => {
handleUserConfigUpdate({ show_subed_only: !userConfig.show_subed_only });
setRefresh(true);
}}
type="checkbox"
checked={userConfig.show_subed_only}
/>
{!userConfig.show_subed_only && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{userConfig.show_subed_only && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only === null ? '' : userConfig.show_subed_only.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefresh(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

@ -4,6 +4,7 @@ import { useOutletContext } from 'react-router-dom';
import iconAdd from '/img/icon-add.svg';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import iconFilter from '/img/icon-filter.svg';
import { OutletContextType } from './Base';
import loadPlaylistList, { PlaylistsResponseType } from '../api/loader/loadPlaylistList';
@ -28,6 +29,7 @@ const Playlists = () => {
const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [showFilterItems, setShowFilterItems] = useState(false);
const [playlistsToAddText, setPlaylistsToAddText] = useState('');
const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState('');
@ -39,7 +41,7 @@ const Playlists = () => {
const pagination = playlistResponseData?.paginate;
const viewStyle = userConfig.view_style_playlist;
const showSubedOnly = userConfig.show_subed_only;
const showSubedOnly = userConfig.show_subed_only_playlists;
useEffect(() => {
(async () => {
@ -53,7 +55,7 @@ const Playlists = () => {
setShowNotification(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, userConfig.show_subed_only, currentPage, pagination?.current_page]);
}, [refresh, userConfig.show_subed_only_playlists, currentPage, pagination?.current_page]);
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
@ -150,29 +152,35 @@ const Playlists = () => {
/>
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
handleUserConfigUpdate({ show_subed_only: !showSubedOnly });
}}
type="checkbox"
/>
{!showSubedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only_playlists === null
? ''
: userConfig.show_subed_only_playlists.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only_playlists:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefresh(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

@ -23,7 +23,8 @@ export const useUserConfigStore = create<UserConfigState>(set => ({
hide_watched: false,
file_size_unit: 'binary',
show_ignored_only: false,
show_subed_only: false,
show_subed_only: null,
show_subed_only_playlists: null,
show_help_text: true,
},
setUserConfig: userConfig => set({ userConfig }),