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"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -165,7 +165,7 @@ export interface Id2 {
export interface GetEpisodesResponse {
resultCount: number;
results: Array<Episode | PodcastDetails>;
results: Array<PodcastDetails>;
}
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: {
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] Shows the episode details (title and description)
- [x] Shows the episode audio player
- [ ] Renders description as HTML
- [x] Renders description as HTML
- [ ] Header
- [ ] Title is a link to the home page