feat: render description's html

chore: refactor to get episodes data from rss feed
This commit is contained in:
2024-04-20 16:58:52 +02:00
parent 8daddc383f
commit 730090ca9e
14 changed files with 1718 additions and 1351 deletions

View File

@@ -16,6 +16,7 @@
"react-router-dom": "^6.22.3" "react-router-dom": "^6.22.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",

2881
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { usePodcastEpisodesQuery } from "../services/podcasts/podcast.hooks"; import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
export function PodcastEpisodesList() { export function PodcastEpisodesList() {
const { podcastId } = useParams<{ podcastId: string }>(); const { podcastId } = useParams<{ podcastId: string }>();
const { data: episodesData } = usePodcastEpisodesQuery(podcastId); const { data: podcast } = usePodcastQuery(podcastId);
return ( return (
<div> <div>
<div>Episodes: {episodesData?.podcast.trackCount}</div> <div>Episodes: {podcast?.trackCount}</div>
<div> <div>
<table> <table>
<thead> <thead>
@@ -17,7 +18,7 @@ export function PodcastEpisodesList() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{episodesData?.episodes?.map((episode) => { {podcast?.episodes?.map((episode) => {
const formattedDate = formatDate(episode.releaseDate); const formattedDate = formatDate(episode.releaseDate);
const formattedDuration = formatDuration(episode.durationSeconds); const formattedDuration = formatDuration(episode.durationSeconds);
const url = `/podcast/${podcastId}/episode/${episode.id}`; const url = `/podcast/${podcastId}/episode/${episode.id}`;

View File

@@ -31,7 +31,7 @@ export function PodcastInfoCard({
<h1>{title}</h1> <h1>{title}</h1>
<h2>{author}</h2> <h2>{author}</h2>
</Wrap> </Wrap>
<p>{description}</p> <p dangerouslySetInnerHTML={{ __html: description }}></p>
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { usePodcastEpisodesQuery } from "../services/podcasts/podcast.hooks"; import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
export function Episode() { export function Episode() {
const { podcastId, episodeId } = useParams<{ const { podcastId, episodeId } = useParams<{
@@ -7,7 +7,7 @@ export function Episode() {
episodeId: string; episodeId: string;
}>(); }>();
const { data: episodesData } = usePodcastEpisodesQuery(podcastId); const { data: episodesData } = usePodcastQuery(podcastId);
const episode = episodesData?.episodes.find( const episode = episodesData?.episodes.find(
(episode) => episode.id.toString() === episodeId, (episode) => episode.id.toString() === episodeId,
@@ -15,7 +15,10 @@ export function Episode() {
return ( return (
<div> <div>
<div>{episode?.description}</div> <div
className={"prose"}
dangerouslySetInnerHTML={{ __html: episode?.description ?? "" }}
></div>
<div> <div>
<audio controls src={episode?.audioUrl}> <audio controls src={episode?.audioUrl}>
Audio is not supported by your browser Audio is not supported by your browser

View File

@@ -1,15 +1,11 @@
import { Outlet, useParams } from "react-router-dom"; import { Outlet, useParams } from "react-router-dom";
import { import { usePodcastQuery } from "../services/podcasts/podcast.hooks";
usePodcastEpisodesQuery,
useTopPodcastsQuery,
} from "../services/podcasts/podcast.hooks";
import { PodcastInfoCard } from "../components/podcast-info-card"; import { PodcastInfoCard } from "../components/podcast-info-card";
export function Podcast() { export function Podcast() {
const { podcastId } = useParams<{ podcastId: string }>(); const { podcastId } = useParams<{ podcastId: string }>();
const { data: podcasts } = useTopPodcastsQuery(); const { data: podcast } = usePodcastQuery(podcastId);
const { data: episodesData } = usePodcastEpisodesQuery(podcastId);
if (!podcastId) { if (!podcastId) {
throw new Error( throw new Error(
@@ -17,12 +13,6 @@ export function Podcast() {
); );
} }
if (!podcasts) {
return null;
}
const podcast = podcasts.find((podcast) => podcast.id === podcastId);
if (!podcast) { if (!podcast) {
return <h1>Podcast not found</h1>; return <h1>Podcast not found</h1>;
} }
@@ -33,7 +23,7 @@ export function Podcast() {
author={podcast.author} author={podcast.author}
title={podcast.title} title={podcast.title}
description={podcast.description} description={podcast.description}
imageURL={episodesData?.podcast.images.large ?? ""} imageURL={podcast.images.large}
/> />
<Outlet /> <Outlet />
</div> </div>

View File

@@ -4,9 +4,13 @@ export class PodcastExtraDTO {
id: number; id: number;
trackCount: number; trackCount: number;
images: { small: string; medium: string; large: string }; images: { small: string; medium: string; large: string };
author: string;
title: string;
constructor(data: PodcastDetails) { constructor(data: PodcastDetails) {
this.id = data.trackId; this.id = data.trackId;
this.author = data.artistName;
this.title = data.collectionName;
this.trackCount = data.trackCount; this.trackCount = data.trackCount;
this.images = { this.images = {
small: data.artworkUrl60, small: data.artworkUrl60,

View File

@@ -13,10 +13,10 @@ export function useTopPodcastsQuery() {
}); });
} }
export function usePodcastEpisodesQuery(podcastId?: string) { export function usePodcastQuery(podcastId?: string) {
return useQuery({ return useQuery({
queryKey: [QUERY_KEYS.PODCAST_EPISODES, podcastId], queryKey: [QUERY_KEYS.PODCAST_EPISODES, podcastId],
queryFn: () => podcastsService.getEpisodesByPodcastId(podcastId ?? ""), queryFn: () => podcastsService.getPodcastById(podcastId ?? ""),
enabled: !!podcastId, enabled: !!podcastId,
}); });
} }

View File

@@ -1,6 +1,6 @@
import { GetEpisodesResponse, GetTopPodcastsResponse } from "./podcasts.types"; import { GetEpisodesResponse, GetTopPodcastsResponse } from "./podcasts.types";
import { PodcastDTO } from "./dto/podcast.dto"; import { PodcastDTO } from "./dto/podcast.dto";
import { EpisodeDto } from "./dto/episode.dto"; import { RssParser } from "../rss-parser";
import { PodcastExtraDTO } from "./dto/podcast-extra.dto"; import { PodcastExtraDTO } from "./dto/podcast-extra.dto";
class PodcastsService { class PodcastsService {
@@ -15,32 +15,46 @@ class PodcastsService {
return data.feed.entry.map((podcast) => new PodcastDTO(podcast)); return data.feed.entry.map((podcast) => new PodcastDTO(podcast));
} }
async getEpisodesByPodcastId(podcastId: string) { async getPodcastById(podcastId: string) {
const url = new URL(`${this.baseUrl}/lookup`); try {
const params = { const url = new URL(`${this.baseUrl}/lookup`);
id: podcastId, const params = {
media: "podcast", id: podcastId,
entity: "podcastEpisode", media: "podcast",
limit: "20", entity: "podcast",
}; limit: "1",
url.search = new URLSearchParams(params).toString(); };
const response: GetEpisodesResponse = await this.fetchWithoutCors( url.search = new URLSearchParams(params).toString();
url.toString(),
);
let podcast: PodcastExtraDTO = {} as PodcastExtraDTO; const response: GetEpisodesResponse = await this.fetchWithoutCors(
const episodes: EpisodeDto[] = []; url.toString(),
);
const podcastExtra = new PodcastExtraDTO(response.results[0]);
const podcastFromFeed = await this.getPodcastFeed(
response.results[0].feedUrl,
);
return { ...podcastExtra, ...podcastFromFeed };
} catch (error) {
console.error("Error fetching podcast data:", error);
throw error;
}
}
response.results.forEach((entry) => { async getPodcastFeed(sourceURL: string) {
if (entry.kind === "podcast-episode") { try {
episodes.push(new EpisodeDto(entry)); // todo: only use allorigins after getting a cors error
} else if (entry.kind === "podcast") { // const response = await fetch(
podcast = new PodcastExtraDTO(entry); // `https://api.allorigins.win/raw?url=${encodeURIComponent(sourceURL)}`,
} // );
}); const response = await fetch(sourceURL);
const rss = await response.text();
return { episodes, podcast: podcast }; return RssParser.parse(rss);
} catch (error) {
console.error("Error fetching and parsing data:", error);
throw error;
}
} }
//TODO: move into a separate service //TODO: move into a separate service

View File

@@ -165,7 +165,7 @@ export interface Id2 {
export interface GetEpisodesResponse { export interface GetEpisodesResponse {
resultCount: number; resultCount: number;
results: Array<Episode | PodcastDetails>; results: Array<PodcastDetails>;
} }
export interface Episode { export interface Episode {

View File

@@ -0,0 +1 @@
export * from "./rss-parser";

View File

@@ -0,0 +1,76 @@
export interface Episode {
id: string;
releaseDate: string;
audioUrl: string;
title: string;
durationSeconds: number;
description: string;
}
export interface Podcast {
description: string;
episodes: Episode[];
}
export class RssParser {
public static parse(rss: string): Podcast {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(rss, "text/xml");
const channel = xmlDoc.querySelector("channel");
const description = this.getElementInnerHtml(channel, "description");
const episodes: Episode[] = [];
const items = Array.from(xmlDoc.querySelectorAll("item"));
items.forEach((item) => {
const id = this.getElementInnerHtml(item, "guid");
const releaseDate = this.getElementInnerHtml(item, "pubDate");
const audioUrl =
this.getElementByTagName(item, "enclosure")?.getAttribute("url") ?? "";
const episodeTitle = this.getElementInnerHtml(item, "title");
const durationSeconds = parseInt(
this.getElementInnerHtml(item, "itunes:duration") ?? "0",
);
const episodeDescription =
this.getElementInnerHtml(item, "content:encoded") ??
this.getElementInnerHtml(item, "description");
episodes.push({
id,
releaseDate,
audioUrl,
title: episodeTitle,
durationSeconds,
description: episodeDescription,
});
});
return {
description,
episodes,
};
}
public static getElementByTagName(element: Element | null, tagName: string) {
return element?.getElementsByTagName(tagName)[0];
}
public static getElementInnerHtml(element: Element | null, tagName: string) {
return this.cleanCDATA(
this.getElementByTagName(element, tagName)?.innerHTML ?? "",
);
}
public static cleanCDATA(data?: string) {
if (!data) {
return "";
}
return data.replace("<![CDATA[", "").replace("]]>", "");
}
}

View File

@@ -4,5 +4,5 @@ export default {
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [require("@tailwindcss/typography")],
}; };

View File

@@ -35,7 +35,7 @@
- [x] Image and title are clickable and navigate to the podcast page - [x] Image and title are clickable and navigate to the podcast page
- [x] Shows the episode details (title and description) - [x] Shows the episode details (title and description)
- [x] Shows the episode audio player - [x] Shows the episode audio player
- [ ] Renders description as HTML - [x] Renders description as HTML
- [ ] Header - [ ] Header
- [ ] Title is a link to the home page - [ ] Title is a link to the home page