Skip to content

Commit

Permalink
Added creating and deleting playlists.
Browse files Browse the repository at this point in the history
  • Loading branch information
SamTV12345 committed Jul 23, 2023
1 parent da3efa3 commit 2db778f
Show file tree
Hide file tree
Showing 16 changed files with 362 additions and 15 deletions.
6 changes: 4 additions & 2 deletions ui/src/components/AddPodcastModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export const AddPodcastModal:FC = () => {
const configModel = useAppSelector(state=>state.common.configModel)

return (
<Modal onCancel={()=>{}} onAccept={()=>{}} headerText={t('add-podcast')!} onDelete={()=>{}} cancelText={"Abbrechen"} acceptText={"Hinzufügen"}>
<AddHeader selectedSearchType={selectedSearchType} setSelectedSearchType={setSelectedSearchType} configModel={configModel} />
<Modal onCancel={()=>{}} onAccept={()=>{}} headerText={t('add-podcast')!} onDelete={()=>{}}
cancelText={"Abbrechen"} acceptText={"Hinzufügen"}>
<AddHeader selectedSearchType={selectedSearchType} setSelectedSearchType={setSelectedSearchType}
configModel={configModel} />

{selectedSearchType !== AddTypes.OPML && selectedSearchType !== AddTypes.FEED &&
<ProviderImportComponent selectedSearchType={selectedSearchType} />
Expand Down
104 changes: 104 additions & 0 deletions ui/src/components/CreatePlaylistModal.tsx
Original file line number Diff line number Diff line change
@@ -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<PlaylistDtoPost>({name: '', items: []})
const [items,setItems] = useState<PodcastEpisode[]>([])
const { formState: {}, handleSubmit, control} = useForm<PlaylistDto>({
defaultValues: {
name: '',
items: []
}
})
const [stage,setStage] = useState<number>(0)

const create_playlist:SubmitHandler<PlaylistDto> = (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(
<div aria-hidden="true" id="defaultModal" onClick={()=>dispatch(setCreatePlaylistOpen(false))} className={`grid place-items-center fixed inset-0 bg-[rgba(0,0,0,0.5)] backdrop-blur overflow-x-hidden overflow-y-auto z-30 ${playListOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`} tabIndex={-1}>

{/* Modal */}
<div className="relative bg-white max-w-5xl p-8 rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.2)]" onClick={e=>e.stopPropagation()}>

{/* Close button */}
<button type="button" className="absolute top-4 right-4 bg-transparent" data-modal-toggle="defaultModal" onClick={()=>dispatch(setCreatePlaylistOpen(false))}>
<span className="material-symbols-outlined text-stone-400 hover:text-stone-600">close</span>
<span className="sr-only">{t('closeModal')}</span>
</button>

{/* Submit form for creating a playlist */}
<form onSubmit={handleSubmit(create_playlist)}>

<div className="mt-5 mb-5 ">
<Heading2 className="mb-4">{t('add-playlist')}</Heading2>
</div>

{
stage === 0 && <PlaylistData control={control}/>
}

{
stage === 1 &&
<PlaylistSearchEpisode items={items} setItems={setItems}/>
}

<div className="flex">
<button type="button">
<span className={`material-symbols-outlined ${stage===0&&'opacity-60'} text-mustard-600`} onClick={()=>{stage>=1&&setStage(stage-1)}}>arrow_back</span>
</button>
<div className="flex-1"></div>
<button type="button" onClick={()=>{stage<=1&&setStage(stage+1)}}>
<span className={`material-symbols-outlined ${stage===2&&'opacity-60'} text-mustard-600`}>arrow_forward</span>
</button>
</div>

{stage === 2 &&
<><CustomButtonPrimary type="submit" className="float-right" onClick={()=>{
axios.post(apiURL+'/playlist', playListDto)
.then((v: AxiosResponse<PlaylistDto>)=>{
enqueueSnackbar(t('invite-created'), {variant: "success"})
dispatch(setPlaylist([...playlists,v.data]))
dispatch(setCreatePlaylistOpen(false))
})
}}>{t('create-playlist')}</CustomButtonPrimary>
<br/>
</>
}
</form>
</div>
</div>, document.getElementById('modal')!
)
}
12 changes: 7 additions & 5 deletions ui/src/components/EpisodeSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {EmptyResultIcon} from "../icons/EmptyResultIcon"

type EpisodeSearchProps = {
classNameResults?: string,
onClickResult?: () => void,
onClickResult?: (e:PodcastEpisode) => void,
resultsMaxHeight?: string,
showBlankState?: boolean
}
Expand Down Expand Up @@ -41,7 +41,9 @@ export const EpisodeSearch: FC<EpisodeSearchProps> = ({classNameResults = '', on
<>
{/* Search field */}
<div className="flex items-center relative">
<CustomInput className="pl-10 w-full" id="search-input" onChange={(v)=>setSearchName(v.target.value)} placeholder={t('search-episodes')!} type="text" value={searchName} />
<CustomInput className="pl-10 w-full" id="search-input"
onChange={(v)=>setSearchName(v.target.value)}
placeholder={t('search-episodes')!} type="text" value={searchName} />

<span className="material-symbols-outlined absolute left-2 text-stone-500">search</span>
</div>
Expand All @@ -54,7 +56,8 @@ export const EpisodeSearch: FC<EpisodeSearchProps> = ({classNameResults = '', on
) : searchResults.length === 0 ? (
<div className="grid place-items-center">
{searchName ? (
<span className="p-8 text-stone-500">{t('no-results-found-for')} "<span className="text-stone-900">{searchName}</span>"</span>
<span className="p-8 text-stone-500">{t('no-results-found-for')} "
<span className="text-stone-900">{searchName}</span>"</span>
) : (
showBlankState && <EmptyResultIcon />
)}
Expand All @@ -63,8 +66,7 @@ export const EpisodeSearch: FC<EpisodeSearchProps> = ({classNameResults = '', on
<ul className={`flex flex-col gap-10 overflow-y-auto my-4 px-8 py-6 scrollbox-y ${classNameResults}`}>
{searchResults.map((episode, i) => (
<li className="flex gap-4 cursor-pointer group" key={i} onClick={()=>{
onClickResult()
navigate(`/podcasts/${episode.podcast_id}/episodes/${episode.id}`)
onClickResult(episode)
}}>
{/* Thumbnail */}
<img alt={episode.name} className="
Expand Down
7 changes: 6 additions & 1 deletion ui/src/components/EpisodeSearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import {useState} from "react"
import {createPortal} from "react-dom"
import {useCtrlPressed, useKeyDown} from "../hooks/useKeyDown"
import {EpisodeSearch} from "./EpisodeSearch"
import {useNavigate} from "react-router-dom";

export const EpisodeSearchModal = () => {
const [open, setOpen] = useState<boolean>(false)
const navigate = useNavigate()

useCtrlPressed(()=>{
setOpen(!open)
Expand All @@ -23,7 +25,10 @@ export const EpisodeSearchModal = () => {
- 24rem
- Or, for when screen height is smaller: viewport height - vertical padding/spacing - height of search field
*/}
<EpisodeSearch onClickResult={() => setOpen(false)} classNameResults="max-h-[min(24rem,calc(100vh-3rem-3rem))]" showBlankState={false} />
<EpisodeSearch onClickResult={(episode) => {
setOpen(false)
navigate(`/podcasts/${episode.podcast_id}/episodes/${episode.id}`)
}} classNameResults="max-h-[min(24rem,calc(100vh-3rem-3rem))]" showBlankState={false} />
</div>
</div>, document.getElementById('modal')!
)
Expand Down
31 changes: 31 additions & 0 deletions ui/src/components/PlaylistData.tsx
Original file line number Diff line number Diff line change
@@ -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<PlaylistDataProps> = ({control})=>{
const {t} = useTranslation()

return <div className="grid grid-cols-1 xs:grid-cols-[1fr_auto] items-center gap-2 xs:gap-6 mb-10">
<fieldset className="xs:contents mb-4">
<label className="ml-2 text-sm text-stone-600" htmlFor="use-existing-filenames">{t('playlist-name')}</label>

<div className="flex flex-col gap-2">
<div className="flex">
<Controller
name="name"
control={control}
render={({ field: { name, onChange, value }}) => (
<CustomInput id="use-existing-filenames" className="border-gray-500 border-2" name={name} onChange={onChange} value ={value} />
)} />

</div>
</div>
</fieldset>
</div>
}
66 changes: 66 additions & 0 deletions ui/src/components/PlaylistSearchEpisode.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<PodcastEpisode[]>>
}


export const PlaylistSearchEpisode:FC<PlaylistSearchEpisodeProps> = ({setItems,items})=>{
const [itemCurrentlyDragged,setItemCurrentlyDragged] = useState<PodcastEpisode>()
const {t} = useTranslation()

const handleDragStart = (dragItem: PodcastEpisode, index: number, event: DragEvent<HTMLTableRowElement> )=>{
event.dataTransfer.setData("text/plain", index.toString())
setItemCurrentlyDragged(dragItem)
}

return <>
<EpisodeSearch onClickResult={(e)=>{
setItems([...items, e])
}} classNameResults="max-h-[min(20rem,calc(100vh-3rem-3rem))]"
showBlankState={false} />
<div className={`scrollbox-x`}>
<table className="text-left text-sm text-stone-900 w-full">
<thead>
<tr className="border-b border-stone-300">
<th scope="col" className="pr-2 py-3">
#
</th>
<th scope="col" className="px-2 py-3">
{t('role')}
</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
return <tr draggable onDrop={e=>{
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)}>
<td>
{index}
</td>
<td>
{item.name}
</td>
</tr>
})}
</tbody>
</table>
</div>
</>
}
5 changes: 4 additions & 1 deletion ui/src/language/json/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion ui/src/language/json/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion ui/src/language/json/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion ui/src/language/json/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
5 changes: 4 additions & 1 deletion ui/src/language/json/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
16 changes: 16 additions & 0 deletions ui/src/models/Playlist.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion ui/src/pages/HomePageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ()=>{
Expand Down Expand Up @@ -35,7 +36,7 @@ export const HomePageSelector = ()=>{
)}

{selectedSection === 'playlist' && (
<UserAdminInvites />
<PlaylistPage />
)}

</>
Expand Down
Loading

0 comments on commit 2db778f

Please sign in to comment.