diff --git a/ui/src/components/AddPodcastModal.tsx b/ui/src/components/AddPodcastModal.tsx index ff412bae..393b4e28 100644 --- a/ui/src/components/AddPodcastModal.tsx +++ b/ui/src/components/AddPodcastModal.tsx @@ -14,8 +14,10 @@ export const AddPodcastModal:FC = () => { const configModel = useAppSelector(state=>state.common.configModel) return ( - {}} onAccept={()=>{}} headerText={t('add-podcast')!} onDelete={()=>{}} cancelText={"Abbrechen"} acceptText={"Hinzufügen"}> - + {}} onAccept={()=>{}} headerText={t('add-podcast')!} onDelete={()=>{}} + cancelText={"Abbrechen"} acceptText={"Hinzufügen"}> + {selectedSearchType !== AddTypes.OPML && selectedSearchType !== AddTypes.FEED && diff --git a/ui/src/components/CreatePlaylistModal.tsx b/ui/src/components/CreatePlaylistModal.tsx new file mode 100644 index 00000000..b5b1b818 --- /dev/null +++ b/ui/src/components/CreatePlaylistModal.tsx @@ -0,0 +1,104 @@ +import {FormEvent, useEffect, useState} from "react" +import {createPortal} from "react-dom" +import {useTranslation} from "react-i18next" +import axios, {AxiosResponse} from "axios" +import {enqueueSnackbar} from "notistack" +import {useAppDispatch, useAppSelector} from "../store/hooks" +import {PodcastEpisode, setCreateInviteModalOpen, setInvites} from "../store/CommonSlice" +import {apiURL} from "../utils/Utilities" +import {CustomButtonPrimary} from "./CustomButtonPrimary" +import {Heading2} from "./Heading2" +import "material-symbols/outlined.css" +import {PlaylistDto, PlaylistDtoPost, PlaylistItem} from "../models/Playlist"; +import {setCreatePlaylistOpen, setPlaylist} from "../store/PlaylistSlice"; +import {SubmitHandler, useFieldArray, useForm} from "react-hook-form"; +import {EpisodeSearch} from "./EpisodeSearch"; +import {DragEvent} from "react"; +import {PlaylistData} from "./PlaylistData"; +import {PlaylistSearchEpisode} from "./PlaylistSearchEpisode"; +export const CreatePlaylistModal = () => { + const dispatch = useAppDispatch() + const playListOpen = useAppSelector(state=>state.playlist.createPlaylistOpen) + const {t} = useTranslation() + const playlists = useAppSelector(state=>state.playlist.playlist) + const [playListDto, setPlayListDto] = useState({name: '', items: []}) + const [items,setItems] = useState([]) + const { formState: {}, handleSubmit, control} = useForm({ + defaultValues: { + name: '', + items: [] + } + }) + const [stage,setStage] = useState(0) + + const create_playlist:SubmitHandler = (data)=>{ + const itemsMappedToIDs = items.map(item=>{ + return { + episode: item.id + } satisfies PlaylistItem + }) + axios.post(apiURL+"/playlist", { + name: data.name, + items: itemsMappedToIDs + } satisfies PlaylistDtoPost) + .then(()=>{ + enqueueSnackbar(t('settings-saved'), {variant: "success"}) + }) + } + + + return createPortal( + , document.getElementById('modal')! + ) +} diff --git a/ui/src/components/EpisodeSearch.tsx b/ui/src/components/EpisodeSearch.tsx index 6b9a40a6..5818105b 100644 --- a/ui/src/components/EpisodeSearch.tsx +++ b/ui/src/components/EpisodeSearch.tsx @@ -11,7 +11,7 @@ import {EmptyResultIcon} from "../icons/EmptyResultIcon" type EpisodeSearchProps = { classNameResults?: string, - onClickResult?: () => void, + onClickResult?: (e:PodcastEpisode) => void, resultsMaxHeight?: string, showBlankState?: boolean } @@ -41,7 +41,9 @@ export const EpisodeSearch: FC = ({classNameResults = '', on <> {/* Search field */}
- setSearchName(v.target.value)} placeholder={t('search-episodes')!} type="text" value={searchName} /> + setSearchName(v.target.value)} + placeholder={t('search-episodes')!} type="text" value={searchName} /> search
@@ -54,7 +56,8 @@ export const EpisodeSearch: FC = ({classNameResults = '', on ) : searchResults.length === 0 ? (
{searchName ? ( - {t('no-results-found-for')} "{searchName}" + {t('no-results-found-for')} " + {searchName}" ) : ( showBlankState && )} @@ -63,8 +66,7 @@ export const EpisodeSearch: FC = ({classNameResults = '', on
    {searchResults.map((episode, i) => (
  • { - onClickResult() - navigate(`/podcasts/${episode.podcast_id}/episodes/${episode.id}`) + onClickResult(episode) }}> {/* Thumbnail */} {episode.name} { const [open, setOpen] = useState(false) + const navigate = useNavigate() useCtrlPressed(()=>{ setOpen(!open) @@ -23,7 +25,10 @@ export const EpisodeSearchModal = () => { - 24rem - Or, for when screen height is smaller: viewport height - vertical padding/spacing - height of search field */} - setOpen(false)} classNameResults="max-h-[min(24rem,calc(100vh-3rem-3rem))]" showBlankState={false} /> + { + setOpen(false) + navigate(`/podcasts/${episode.podcast_id}/episodes/${episode.id}`) + }} classNameResults="max-h-[min(24rem,calc(100vh-3rem-3rem))]" showBlankState={false} />
, document.getElementById('modal')! ) diff --git a/ui/src/components/PlaylistData.tsx b/ui/src/components/PlaylistData.tsx new file mode 100644 index 00000000..dc0dc078 --- /dev/null +++ b/ui/src/components/PlaylistData.tsx @@ -0,0 +1,31 @@ +import {CustomInput} from "./CustomInput"; +import {Controller} from "react-hook-form"; +import {useTranslation} from "react-i18next"; +import {FC} from "react"; + +type PlaylistDataProps = { + control: any +} + + +export const PlaylistData:FC = ({control})=>{ + const {t} = useTranslation() + + return
+
+ + +
+
+ ( + + )} /> + +
+
+
+
+} diff --git a/ui/src/components/PlaylistSearchEpisode.tsx b/ui/src/components/PlaylistSearchEpisode.tsx new file mode 100644 index 00000000..1b2d2773 --- /dev/null +++ b/ui/src/components/PlaylistSearchEpisode.tsx @@ -0,0 +1,66 @@ +import {EpisodeSearch} from "./EpisodeSearch"; +import {Dispatch, DragEvent, FC, SetStateAction, useState} from "react"; +import {PodcastEpisode} from "../store/CommonSlice"; +import {useTranslation} from "react-i18next"; + +type PlaylistSearchEpisodeProps = { + items: PodcastEpisode[], + setItems: Dispatch> +} + + +export const PlaylistSearchEpisode:FC = ({setItems,items})=>{ + const [itemCurrentlyDragged,setItemCurrentlyDragged] = useState() + const {t} = useTranslation() + + const handleDragStart = (dragItem: PodcastEpisode, index: number, event: DragEvent )=>{ + event.dataTransfer.setData("text/plain", index.toString()) + setItemCurrentlyDragged(dragItem) + } + + return <> + { + setItems([...items, e]) + }} classNameResults="max-h-[min(20rem,calc(100vh-3rem-3rem))]" + showBlankState={false} /> +
+ + + + + + + + + {items.map((item, index) => { + return { + e.preventDefault() + const dropIndex = index + const dragIndex = parseInt(e.dataTransfer.getData("text/plain")) + + setItems((items)=>{ + // @ts-ignore + const newItems = [...items] + const dragItem = newItems[dragIndex] + newItems.splice(dragIndex, 1) + newItems.splice(dropIndex, 0, dragItem) + return newItems + }) + }} onDragOver={(e)=>item.id!=itemCurrentlyDragged?.id&&e.preventDefault()} onDragStart={e=>handleDragStart(item, index, e)}> + + + + })} + +
+ # + + {t('role')} +
+ {index} + + {item.name} +
+
+ +} diff --git a/ui/src/language/json/de.json b/ui/src/language/json/de.json index a23d3a23..12b74831 100644 --- a/ui/src/language/json/de.json +++ b/ui/src/language/json/de.json @@ -150,5 +150,8 @@ "standard-podcast-format-explanation":"Gibt das Standardformat für die Podcasts an. Dies wirkt sich lediglich auf die Ordnerstruktur und deren Benamung aus.", "colon-replacement-explanation": "Gibt an, durch welches Zeichen der Doppelpunkt ersetzt werden soll. Dies ist nützlich, da der Doppelpunkt nicht im Dateinamen verwendet werden kann.", "already-added": "Podcast {{name}} bereits hinzugefügt", - "playlists": "Playlisten" + "playlists": "Playlisten", + "add-playlist": "Playlist hinzufügen", + "create-playlist": "Playlist erstellen", + "playlist-name": "Name der Playlist" } diff --git a/ui/src/language/json/en.json b/ui/src/language/json/en.json index 3c7cbe37..aca71e4a 100644 --- a/ui/src/language/json/en.json +++ b/ui/src/language/json/en.json @@ -151,5 +151,8 @@ "standard-episode-format-explanation": "Specifies the standard format for the episodes. This only affects the folder structure and naming.", "colon-replacement-explanation": "Specifies by which character the colon should be replaced. This is useful because the colon cannot be used in the file name.", "already-added": "Podcast {{name}} already added", - "playlists": "Playlists" + "playlists": "Playlists", + "add-playlist": "Add playlist", + "create-playlist": "Create playlist", + "playlist-name": "Playlist name" } diff --git a/ui/src/language/json/es.json b/ui/src/language/json/es.json index 4b858a3b..f366d1b5 100644 --- a/ui/src/language/json/es.json +++ b/ui/src/language/json/es.json @@ -149,5 +149,8 @@ "default-episode-format-explanation": "Especifica el formato por defecto para los episodios. Solo afecta la estructura de directorio y su nomenclatura.", "standard-podcast-format-explanation": "Especifica el formato estándar para los podcasts. Solo afecta la estructura de directorio y su nomenclatura", "colon-replacement-explanation": "Especifica el carácter que sustituirá a los dos puntos. Esto es útil ya que los dos puntos no se pueden usar en un nombre de fichero.", - "playlists": "Listas de reproducción" + "playlists": "Listas de reproducción", + "add-playlist": "Añadir lista de reproducción", + "create-playlist": "Crear lista de reproducción", + "playlist-name": "Nombre de la lista de reproducción" } diff --git a/ui/src/language/json/fr.json b/ui/src/language/json/fr.json index e2642d04..aff96cd4 100644 --- a/ui/src/language/json/fr.json +++ b/ui/src/language/json/fr.json @@ -150,5 +150,8 @@ "standard-podcast-format-explanation" : "Indique le format par défaut pour les podcasts. Cela n'affecte que la structure des dossiers et leur dénomination.", "colon-replacement-explanation" : "Indique par quel caractère le deux-points doit être remplacé. Ceci est utile car le deux-points ne peut pas être utilisé dans le nom de fichier", "already-added": "Podcast {{name}} déjà ajouté", - "playlists": "Playlists" + "playlists": "Playlists", + "add-playlist": "Ajouter une playlist", + "create-playlist": "Créer une playlist", + "playlist-name": "Nom de la playlist" } diff --git a/ui/src/language/json/pl.json b/ui/src/language/json/pl.json index 76a8fad6..8ebe1e81 100644 --- a/ui/src/language/json/pl.json +++ b/ui/src/language/json/pl.json @@ -150,5 +150,8 @@ "standard-podcast-format-explanation": "Format nazw podcastów. To ustawienie dotyczy jedynie struktury plików i katalogów oraz ich nazewnictwa.", "colon-replacement-explanation": "Określa sposób zamiany dwukropka na inny znak. PodFetch nie zezwala na występowanie dwukropków w nazwach plików.", "already-added": "Podcast {{name}} został już dodany.", - "playlists": "Playlisty" + "playlists": "Playlisty", + "add-playlist": "Dodaj playlistę", + "create-playlist": "Stwórz playlistę", + "playlist-name": "Nazwa playlisty" } diff --git a/ui/src/models/Playlist.ts b/ui/src/models/Playlist.ts new file mode 100644 index 00000000..e29fb66d --- /dev/null +++ b/ui/src/models/Playlist.ts @@ -0,0 +1,16 @@ +import {PodcastEpisode} from "../store/CommonSlice"; + +export type PlaylistDto = { + id: number, + name: string, + items: PodcastEpisode[] +} + +export type PlaylistDtoPost = { + name: string, + items: PlaylistItem[] +} + +export type PlaylistItem = { + episode: number +} diff --git a/ui/src/pages/HomePageSelector.tsx b/ui/src/pages/HomePageSelector.tsx index 93a7e952..b75143ac 100644 --- a/ui/src/pages/HomePageSelector.tsx +++ b/ui/src/pages/HomePageSelector.tsx @@ -4,6 +4,7 @@ import {Heading1} from "../components/Heading1"; import {UserAdminUsers} from "../components/UserAdminUsers"; import {UserAdminInvites} from "../components/UserAdminInvites"; import {Homepage} from "./Homepage"; +import {PlaylistPage} from "./PlaylistPage"; type SelectableSection = 'home'|'playlist' export const HomePageSelector = ()=>{ @@ -35,7 +36,7 @@ export const HomePageSelector = ()=>{ )} {selectedSection === 'playlist' && ( - + )} diff --git a/ui/src/pages/PlaylistPage.tsx b/ui/src/pages/PlaylistPage.tsx new file mode 100644 index 00000000..21bfa3e3 --- /dev/null +++ b/ui/src/pages/PlaylistPage.tsx @@ -0,0 +1,76 @@ +import {CreateInviteModal} from "../components/CreateInviteModal"; +import {CustomButtonPrimary} from "../components/CustomButtonPrimary"; +import {setCreateInviteModalOpen, setInvites} from "../store/CommonSlice"; +import axios, {AxiosResponse} from "axios"; +import {apiURL, formatTime} from "../utils/Utilities"; +import {useAppDispatch, useAppSelector} from "../store/hooks"; +import {useTranslation} from "react-i18next"; +import {enqueueSnackbar} from "notistack"; +import {Simulate} from "react-dom/test-utils"; +import play = Simulate.play; +import {setCreatePlaylistOpen, setPlaylist} from "../store/PlaylistSlice"; +import {useEffect} from "react"; +import {PlaylistDto} from "../models/Playlist"; +import {CreatePlaylistModal} from "../components/CreatePlaylistModal"; + +export const PlaylistPage = ()=>{ + const dispatch = useAppDispatch() + const {t} = useTranslation() + const playlist = useAppSelector(state=>state.playlist.playlist) + + useEffect(()=>{ + if (playlist.length ===0){ + axios.get(apiURL+"/playlist").then((response:AxiosResponse)=>{ + dispatch(setPlaylist(response.data)) + }) + } + },[]) + + return ( +
+ + + { + dispatch(setCreatePlaylistOpen(true)) + }}> + add {t('add-new')} + + +
+ + + + + + + + {playlist.map(i=> + + + + + )} + +
+ {t('playlist-name')} +
+ {i.name} + + +
+
+
) +} diff --git a/ui/src/store/PlaylistSlice.ts b/ui/src/store/PlaylistSlice.ts new file mode 100644 index 00000000..2df4aaac --- /dev/null +++ b/ui/src/store/PlaylistSlice.ts @@ -0,0 +1,27 @@ +import {createSlice, PayloadAction} from "@reduxjs/toolkit"; +import {PlaylistDto} from "../models/Playlist"; + +interface PlaylistState { + playlist: PlaylistDto[], + createPlaylistOpen: boolean +} + +const initialState: PlaylistState = { + playlist: [], + createPlaylistOpen: false +} +export const PlaylistSlice = createSlice({ + name: 'playlist', + initialState, + reducers:{ + setPlaylist: (state, action: PayloadAction)=>{ + state.playlist = action.payload + }, + setCreatePlaylistOpen: (state, action: PayloadAction)=>{ + state.createPlaylistOpen = action.payload + } + } +}) + +export const {setPlaylist, setCreatePlaylistOpen} = PlaylistSlice.actions +export default PlaylistSlice.reducer diff --git a/ui/src/store/store.ts b/ui/src/store/store.ts index 459c81c9..c185d9c1 100644 --- a/ui/src/store/store.ts +++ b/ui/src/store/store.ts @@ -4,13 +4,15 @@ import {AudioPlayerSlice} from "./AudioPlayerSlice"; import {modalSlice} from "./ModalSlice"; import {opmlImportSlice} from "./opmlImportSlice"; import {podcastSearch} from "./podcastSearch"; +import {PlaylistSlice} from "./PlaylistSlice"; export const store = configureStore({ reducer: { common: commonSlice.reducer, audioPlayer: AudioPlayerSlice.reducer, modal: modalSlice.reducer, opmlImport: opmlImportSlice.reducer, - podcastSearch: podcastSearch + podcastSearch: podcastSearch, + playlist: PlaylistSlice.reducer }, })