mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
add prototype podcast views
This commit is contained in:
parent
b185b0a6ed
commit
c940b42ed3
@ -114,6 +114,7 @@
|
||||
"three-stdlib": "^2.35.2",
|
||||
"tiny-lru": "^11.2.11",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^11.0.4",
|
||||
"vite-plugin-funding": "^0.1.0",
|
||||
"webln": "^0.3.2",
|
||||
"workbox-core": "7.0.0",
|
||||
|
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -306,6 +306,9 @@ importers:
|
||||
unified:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
uuid:
|
||||
specifier: ^11.0.4
|
||||
version: 11.0.4
|
||||
vite-plugin-funding:
|
||||
specifier: ^0.1.0
|
||||
version: 0.1.0
|
||||
@ -4327,6 +4330,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
uuid@11.0.4:
|
||||
resolution: {integrity: sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
@ -9359,6 +9366,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
uuid@11.0.4: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
valid-url@1.0.9: {}
|
||||
|
17
src/app.tsx
17
src/app.tsx
@ -144,6 +144,10 @@ const WikiCompareView = lazy(() => import("./views/wiki/compare"));
|
||||
const CreateWikiPageView = lazy(() => import("./views/wiki/create"));
|
||||
const EditWikiPageView = lazy(() => import("./views/wiki/edit"));
|
||||
|
||||
const PodcastsHomeView = lazy(() => import("./views/podcasts"));
|
||||
const PodcastView = lazy(() => import("./views/podcasts/podcast"));
|
||||
const EpisodeView = lazy(() => import("./views/podcasts/podcast/episode"));
|
||||
|
||||
const RootPage = () => {
|
||||
useSetColorMode();
|
||||
|
||||
@ -365,6 +369,19 @@ const router = createHashRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "podcasts",
|
||||
element: (
|
||||
<RequireCurrentAccount>
|
||||
<Outlet />
|
||||
</RequireCurrentAccount>
|
||||
),
|
||||
children: [
|
||||
{ path: "", element: <PodcastsHomeView /> },
|
||||
{ path: ":guid", element: <PodcastView /> },
|
||||
{ path: ":guid/:episode", element: <EpisodeView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "videos",
|
||||
children: [
|
||||
|
@ -1,6 +1,6 @@
|
||||
import debug from "debug";
|
||||
|
||||
if (!localStorage.getItem("debug") && import.meta.env.DEV)
|
||||
debug.enable("noStrudel,noStrudel:*,applesauce,applesauce:*");
|
||||
// if (!localStorage.getItem("debug") && import.meta.env.DEV)
|
||||
// debug.enable("noStrudel,noStrudel:*,applesauce,applesauce:*");
|
||||
|
||||
export const logger = debug("noStrudel");
|
||||
|
118
src/helpers/nostr/podcasts.ts
Normal file
118
src/helpers/nostr/podcasts.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { getHiddenTags, processTags } from "applesauce-core/helpers";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { v5 as UUIDv5 } from "uuid";
|
||||
|
||||
export const PODCAST_GUID_NS = "ead4c236-bf58-58c6-a2c6-a6b28d128cb6";
|
||||
export const PODCASTS_LIST_KIND = 10104;
|
||||
|
||||
export const NAMESPACES = {
|
||||
atom: "http://www.w3.org/2005/Atom",
|
||||
itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd",
|
||||
podcast: "https://podcastindex.org/namespace/1.0",
|
||||
spotify: "http://www.spotify.com/ns/rss",
|
||||
content: "http://purl.org/rss/1.0/modules/content/",
|
||||
} as const;
|
||||
|
||||
const resolver: XPathNSResolver = (prefix: string | null): string | null => {
|
||||
return (prefix && NAMESPACES[prefix as keyof typeof NAMESPACES]) || null;
|
||||
};
|
||||
|
||||
export type FeedPointer = {
|
||||
guid: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export function getFeedPointerFromITag(tag: string[]): FeedPointer {
|
||||
if (tag.length < 3) throw new Error("Tag too short");
|
||||
|
||||
const guid = tag[1];
|
||||
const url = new URL(tag[2]);
|
||||
|
||||
return { guid, url };
|
||||
}
|
||||
|
||||
export function getFeedPointers(list: NostrEvent) {
|
||||
const hidden = getHiddenTags(list);
|
||||
return processTags(hidden ? [...hidden, ...list.tags] : list.tags, getFeedPointerFromITag);
|
||||
}
|
||||
|
||||
function getNodeDocument(xml: Document | Node | Element) {
|
||||
if (xml instanceof Document) return xml;
|
||||
else if (xml instanceof Element) return xml.ownerDocument;
|
||||
else if (!xml.parentElement) throw new Error("Disconnected node");
|
||||
else return xml.parentElement instanceof Document ? xml.parentElement : xml.parentElement.ownerDocument;
|
||||
}
|
||||
|
||||
export function getXPathString(xml: Document | Element, selector: string): string;
|
||||
export function getXPathString(xml: Document | Element, selector: string, safe: true): string | undefined;
|
||||
export function getXPathString(xml: Document | Element, selector: string, safe: false): string;
|
||||
export function getXPathString(xml: Document | Element, selector: string, safe = false) {
|
||||
try {
|
||||
return getNodeDocument(xml).evaluate(selector, xml, resolver, XPathResult.STRING_TYPE).stringValue;
|
||||
} catch (error) {
|
||||
if (!safe) throw error;
|
||||
}
|
||||
}
|
||||
export function getXPathElements(node: Document | Element, selector: string): Element[] {
|
||||
const iterator = getNodeDocument(node).evaluate(selector, node, resolver, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
|
||||
const items: Element[] = [];
|
||||
|
||||
let item: Node | null;
|
||||
while ((item = iterator.iterateNext())) {
|
||||
if (item instanceof Element) items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function getPodcastTitle(xml: Document): string {
|
||||
return getXPathString(xml, "//title");
|
||||
}
|
||||
export function getPodcastDescription(xml: Document): string {
|
||||
return getXPathString(xml, "//description");
|
||||
}
|
||||
export function getPodcastImageURL(xml: Document): string {
|
||||
return xml.evaluate("//image/url", xml, resolver, XPathResult.STRING_TYPE).stringValue;
|
||||
}
|
||||
export function getPodcastLink(xml: Document): string {
|
||||
return getXPathString(xml, "//link");
|
||||
}
|
||||
export function getPodcastFeedGUID(url: string) {
|
||||
return UUIDv5(url.replace(/http?s:\/\//i, ""), PODCAST_GUID_NS);
|
||||
}
|
||||
export function getPodcastGUID(xml: Document): string {
|
||||
let guid = xml.evaluate("//podcast:guid", xml, resolver, XPathResult.STRING_TYPE).stringValue;
|
||||
if (guid) return guid;
|
||||
|
||||
const link = getXPathString(xml, "//atom:link/@href");
|
||||
return getPodcastFeedGUID(link);
|
||||
}
|
||||
|
||||
export function getPodcastItems(xml: Document) {
|
||||
return getXPathElements(xml, "//item");
|
||||
}
|
||||
|
||||
export type PodcastPerson = {
|
||||
href?: string;
|
||||
image?: string;
|
||||
name: string;
|
||||
group?: string;
|
||||
roll?: string;
|
||||
};
|
||||
export function getPodcastPeople(xml: Document | Element) {
|
||||
const items = getXPathElements(xml, "podcast:person");
|
||||
const people: PodcastPerson[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.textContent) continue;
|
||||
|
||||
people.push({
|
||||
name: item.textContent,
|
||||
image: item.getAttribute("img") ?? undefined,
|
||||
href: item.getAttribute("href") ?? undefined,
|
||||
group: item.getAttribute("group") ?? undefined,
|
||||
roll: item.getAttribute("roll") ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return people;
|
||||
}
|
8
src/hooks/use-feed-xml.ts
Normal file
8
src/hooks/use-feed-xml.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { useAsync } from "react-use";
|
||||
import { xmlFeedsService } from "../services/xml-feeds";
|
||||
|
||||
export default function useFeedXML(url: string | URL, force?: boolean) {
|
||||
const { error, value, loading } = useAsync(() => xmlFeedsService.requestFeed(url, force), [String(url), force]);
|
||||
|
||||
return { xml: value, error, loading };
|
||||
}
|
15
src/hooks/use-user-podcasts.ts
Normal file
15
src/hooks/use-user-podcasts.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import useReplaceableEvent from "./use-replaceable-event";
|
||||
import useCurrentAccount from "./use-current-account";
|
||||
import { RequestOptions } from "../services/replaceable-events";
|
||||
import { PODCASTS_LIST_KIND } from "../helpers/nostr/podcasts";
|
||||
|
||||
export default function useUserPodcasts(
|
||||
pubkey?: string,
|
||||
additionalRelays?: Iterable<string>,
|
||||
opts: RequestOptions = {},
|
||||
) {
|
||||
const account = useCurrentAccount();
|
||||
pubkey = pubkey || account?.pubkey;
|
||||
|
||||
return useReplaceableEvent(pubkey ? { kind: PODCASTS_LIST_KIND, pubkey } : undefined, additionalRelays, opts);
|
||||
}
|
29
src/services/xml-feeds.ts
Normal file
29
src/services/xml-feeds.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { fetchWithProxy } from "../helpers/request";
|
||||
|
||||
class XmlFeedsService {
|
||||
parser = new DOMParser();
|
||||
feeds = new Map<string, Document>();
|
||||
|
||||
private async loadFeed(url: string) {
|
||||
const str = await fetchWithProxy(url).then((res) => res.text());
|
||||
|
||||
return this.parser.parseFromString(str, "application/xml");
|
||||
}
|
||||
|
||||
async requestFeed(url: string | URL, force = false): Promise<Document> {
|
||||
url = String(url);
|
||||
|
||||
if (this.feeds.has(url) && !force) return this.feeds.get(url)!;
|
||||
|
||||
const xml = await this.loadFeed(url);
|
||||
this.feeds.set(url, xml);
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
|
||||
export const xmlFeedsService = new XmlFeedsService();
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-expect-error
|
||||
window.xmlFeedsService = xmlFeedsService;
|
||||
}
|
@ -23,6 +23,7 @@ import MessageQuestionSquare from "../../components/icons/message-question-squar
|
||||
import UploadCloud01 from "../../components/icons/upload-cloud-01";
|
||||
import Edit04 from "../../components/icons/edit-04";
|
||||
import Users03 from "../../components/icons/users-03";
|
||||
import Podcast from "../../components/icons/podcast";
|
||||
|
||||
export const internalApps: App[] = [
|
||||
{
|
||||
@ -39,6 +40,7 @@ export const internalApps: App[] = [
|
||||
id: "media",
|
||||
to: "/media",
|
||||
},
|
||||
// { title: "Podcasts", description: "Social podcasts", icon: Podcast, id: "podcasts", to: "/podcasts" },
|
||||
{ title: "Wiki", description: "Browse wiki pages", icon: WikiIcon, id: "wiki", to: "/wiki" },
|
||||
{
|
||||
title: "Channels",
|
||||
|
91
src/views/podcasts/components/add-feed-form.tsx
Normal file
91
src/views/podcasts/components/add-feed-form.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Switch, useToast } from "@chakra-ui/react";
|
||||
import { EventTemplate, NostrEvent } from "nostr-tools";
|
||||
import { useForm } from "react-hook-form";
|
||||
import useUserPodcasts from "../../../hooks/use-user-podcasts";
|
||||
import { ErrorBoundary } from "../../../components/error-boundary";
|
||||
import PodcastFeedCard from "./podcast-feed-card";
|
||||
import { usePublishEvent } from "../../../providers/global/publish-provider";
|
||||
import { getFeedPointers, getPodcastGUID, PODCASTS_LIST_KIND } from "../../../helpers/nostr/podcasts";
|
||||
import { xmlFeedsService } from "../../../services/xml-feeds";
|
||||
import { unixNow } from "applesauce-core/helpers";
|
||||
|
||||
export default function AddFeedForm({ onAdded }: { onAdded?: (list: NostrEvent) => void }) {
|
||||
const toast = useToast();
|
||||
const publish = usePublishEvent();
|
||||
const { register, formState, handleSubmit, watch, getValues } = useForm({ defaultValues: { url: "", public: true } });
|
||||
const list = useUserPodcasts();
|
||||
|
||||
watch("url");
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
const url = new URL(values.url).toString();
|
||||
const xml = await xmlFeedsService.requestFeed(url);
|
||||
if (!xml) throw new Error("Failed to fetch feed");
|
||||
|
||||
const guid = getPodcastGUID(xml);
|
||||
|
||||
let draft: EventTemplate;
|
||||
if (list) {
|
||||
if (getFeedPointers(list).some((f) => f.guid === guid)) throw new Error("Already subscribed");
|
||||
|
||||
draft = {
|
||||
kind: PODCASTS_LIST_KIND,
|
||||
created_at: unixNow(),
|
||||
content: list.content,
|
||||
tags: [...list.tags, ["i", guid, url]],
|
||||
};
|
||||
} else {
|
||||
draft = {
|
||||
kind: PODCASTS_LIST_KIND,
|
||||
tags: [["i", guid, url]],
|
||||
created_at: unixNow(),
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
const pub = await publish("Add Podcast", draft);
|
||||
if (pub) onAdded?.(pub?.event);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast({ status: "error", description: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
const url = getValues("url");
|
||||
|
||||
return (
|
||||
<Flex as="form" gap="2" direction="column" onSubmit={submit}>
|
||||
<FormControl>
|
||||
<FormLabel>Feed URL</FormLabel>
|
||||
<Input
|
||||
type="url"
|
||||
{...register("url", { required: true })}
|
||||
isRequired
|
||||
placeholder="https://best-podcast.com/feed.xml"
|
||||
/>
|
||||
<FormHelperText>Enter the feed URL of a podcast</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="public" mb="0">
|
||||
Public subscription
|
||||
</FormLabel>
|
||||
<Switch id="public" {...register("public")} isDisabled />
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
{url && URL.canParse(url) && (
|
||||
<ErrorBoundary>
|
||||
<PodcastFeedCard pointer={{ guid: "", url: new URL(url) }} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
<Flex justifyContent="flex-end">
|
||||
<Button type="submit" isLoading={formState.isLoading} colorScheme="primary">
|
||||
Add
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
36
src/views/podcasts/components/episode-card.tsx
Normal file
36
src/views/podcasts/components/episode-card.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Card, CardBody, CardHeader, Flex, Heading, Image, LinkBox } from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { getXPathString } from "../../../helpers/nostr/podcasts";
|
||||
import HoverLinkOverlay from "../../../components/hover-link-overlay";
|
||||
|
||||
export default function EpisodeCard({ episode }: { episode: Element }) {
|
||||
const image = getXPathString(episode, "itunes:image");
|
||||
const title = getXPathString(episode, "title");
|
||||
const description = getXPathString(episode, "itunes:summary");
|
||||
const guid = getXPathString(episode, "guid");
|
||||
|
||||
const { guid: podcastGuid } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
|
||||
return (
|
||||
<Card as={LinkBox} flexDirection="row" overflow="hidden">
|
||||
{image && <Image src={image} maxH="24" w="auto" />}
|
||||
<Flex direction="column" overflow="hidden" w="full">
|
||||
<CardHeader p="2" alignItems="center">
|
||||
<Heading size="sm">
|
||||
<HoverLinkOverlay
|
||||
as={RouterLink}
|
||||
to={{ pathname: `/podcasts/${podcastGuid}/${encodeURIComponent(guid)}`, search: search.toString() }}
|
||||
>
|
||||
{title}
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody display="block" px="2" pb="2" pt="0" noOfLines={2}>
|
||||
{description}
|
||||
</CardBody>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
49
src/views/podcasts/components/podcast-feed-card.tsx
Normal file
49
src/views/podcasts/components/podcast-feed-card.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { Card, CardBody, CardHeader, Flex, Heading, Image, LinkBox, Spinner } from "@chakra-ui/react";
|
||||
import { To, Link as RouterLink } from "react-router-dom";
|
||||
|
||||
import {
|
||||
FeedPointer,
|
||||
getPodcastDescription,
|
||||
getPodcastImageURL,
|
||||
getPodcastTitle,
|
||||
} from "../../../helpers/nostr/podcasts";
|
||||
import useFeedXML from "../../../hooks/use-feed-xml";
|
||||
import HoverLinkOverlay from "../../../components/hover-link-overlay";
|
||||
|
||||
export default function PodcastFeedCard({ pointer, to }: { pointer: FeedPointer; to?: To }) {
|
||||
const { xml } = useFeedXML(pointer.url);
|
||||
|
||||
if (!xml)
|
||||
return (
|
||||
<Card as={to ? LinkBox : "div"}>
|
||||
<CardHeader p="4" alignItems="center">
|
||||
<HoverLinkOverlay as={RouterLink} to={to}>
|
||||
<Spinner /> Fetching feed...
|
||||
</HoverLinkOverlay>
|
||||
</CardHeader>
|
||||
<CardBody px="4" pb="4" pt="0">
|
||||
{pointer.url.toString()}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const image = getPodcastImageURL(xml);
|
||||
|
||||
return (
|
||||
<Card as={to ? LinkBox : "div"} flexDirection="row" overflow="hidden">
|
||||
{image && <Image src={image} maxH="24" w="auto" />}
|
||||
<Flex direction="column" overflow="hidden" w="full">
|
||||
<CardHeader p="2" alignItems="center">
|
||||
<Heading size="sm">
|
||||
<HoverLinkOverlay as={RouterLink} to={to}>
|
||||
{getPodcastTitle(xml)}
|
||||
</HoverLinkOverlay>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody display="block" px="2" pb="2" pt="0" noOfLines={2}>
|
||||
{getPodcastDescription(xml)}
|
||||
</CardBody>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
65
src/views/podcasts/index.tsx
Normal file
65
src/views/podcasts/index.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import { useAppTitle } from "../../hooks/use-app-title";
|
||||
import { useBreakpointValue } from "../../providers/global/breakpoint-provider";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
import Plus from "../../components/icons/plus";
|
||||
import useUserPodcasts from "../../hooks/use-user-podcasts";
|
||||
import { getFeedPointers } from "../../helpers/nostr/podcasts";
|
||||
import PodcastFeedCard from "./components/podcast-feed-card";
|
||||
import AddFeedForm from "./components/add-feed-form";
|
||||
|
||||
export default function PodcastsHomeView() {
|
||||
useAppTitle("Podcasts");
|
||||
|
||||
const add = useDisclosure();
|
||||
|
||||
const list = useUserPodcasts();
|
||||
const feeds = list ? getFeedPointers(list) : [];
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex>
|
||||
<Heading size="lg">Podcasts</Heading>
|
||||
<Button ms="auto" leftIcon={<Plus boxSize={6} />} colorScheme="primary" onClick={add.onOpen}>
|
||||
Add
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Heading size="md">Subscribed</Heading>
|
||||
<SimpleGrid columns={{ base: 1, lg: 2 }} gap="2">
|
||||
{feeds.map((feed) => (
|
||||
<PodcastFeedCard
|
||||
key={feed.guid}
|
||||
pointer={feed}
|
||||
to={`/podcasts/${feed.guid}?feed=${encodeURIComponent(feed.url.toString())}`}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{add.isOpen && (
|
||||
<Modal isOpen={add.isOpen} onClose={add.onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader p="4">Add feed</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px="4" pb="4" pt="0">
|
||||
<AddFeedForm onAdded={add.onClose} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
46
src/views/podcasts/podcast/episode/index.tsx
Normal file
46
src/views/podcasts/podcast/episode/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Navigate, useParams, useSearchParams, Link as RouterLink } from "react-router-dom";
|
||||
import VerticalPageLayout from "../../../../components/vertical-page-layout";
|
||||
import { Button, Flex, Heading, Image, Spinner, Text } from "@chakra-ui/react";
|
||||
import ChevronLeft from "../../../../components/icons/chevron-left";
|
||||
import useFeedXML from "../../../../hooks/use-feed-xml";
|
||||
import { getXPathElements, getXPathString } from "../../../../helpers/nostr/podcasts";
|
||||
import BackButton from "../../../../components/router/back-button";
|
||||
|
||||
function EpisodePage({ episode }: { episode: Element }) {
|
||||
const image = getXPathString(episode, "itunes:image");
|
||||
const title = getXPathString(episode, "title");
|
||||
const description = getXPathString(episode, "itunes:summary");
|
||||
const podcastGUID = getXPathString(episode, "//guid");
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
{image && <Image src={image} maxH="24" w="auto" />}
|
||||
<Flex direction="column" overflow="hidden" w="full">
|
||||
<Heading size="lg">{title}</Heading>
|
||||
<Text>{description}</Text>
|
||||
</Flex>
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EpisodeView() {
|
||||
const { guid, episode } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const url = search.get("feed") || search.get("url");
|
||||
|
||||
if (!url || !guid || !episode) return <Navigate to="/podcasts" />;
|
||||
|
||||
const { xml } = useFeedXML(url);
|
||||
if (!xml) return <Spinner />;
|
||||
|
||||
let episodeXml: Element | undefined = undefined;
|
||||
const items = getXPathElements(xml, "//item");
|
||||
if (Number.isFinite(parseInt(episode))) {
|
||||
episodeXml = items[parseInt(episode)];
|
||||
} else {
|
||||
episodeXml = items.find((item) => getXPathString(item, "guid") === episode);
|
||||
}
|
||||
if (!episodeXml) throw new Error(`Cant find episode ${episode}`);
|
||||
|
||||
return <EpisodePage episode={episodeXml} />;
|
||||
}
|
106
src/views/podcasts/podcast/index.tsx
Normal file
106
src/views/podcasts/podcast/index.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { Navigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
FeedPointer,
|
||||
getPodcastDescription,
|
||||
getPodcastImageURL,
|
||||
getPodcastItems,
|
||||
getPodcastPeople,
|
||||
getPodcastTitle,
|
||||
getXPathString,
|
||||
} from "../../../helpers/nostr/podcasts";
|
||||
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||
import useFeedXML from "../../../hooks/use-feed-xml";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Box,
|
||||
ButtonGroup,
|
||||
CloseButton,
|
||||
Flex,
|
||||
Heading,
|
||||
IconButton,
|
||||
Image,
|
||||
Link,
|
||||
Spinner,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMemo } from "react";
|
||||
import EpisodeCard from "../components/episode-card";
|
||||
import Rss01 from "../../../components/icons/rss-01";
|
||||
|
||||
function PodcastPage({ pointer, xml }: { pointer: FeedPointer; xml: Document }) {
|
||||
const image = getPodcastImageURL(xml);
|
||||
const episodes = useMemo(() => getPodcastItems(xml), [xml]);
|
||||
|
||||
const people = getPodcastPeople(xml);
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Flex gap="4">
|
||||
<Image maxH="32" src={image} />
|
||||
<Box>
|
||||
<Heading size="lg">{getPodcastTitle(xml)}</Heading>
|
||||
<Text>{getPodcastDescription(xml)}</Text>
|
||||
{people.length > 0 && (
|
||||
<AvatarGroup>
|
||||
{people.map((person) => (
|
||||
<Avatar key={person.name} name={person.name} src={person.image} />
|
||||
))}
|
||||
</AvatarGroup>
|
||||
)}
|
||||
<ButtonGroup variant="ghost" size="sm">
|
||||
<IconButton as={Link} href={pointer.url.toString()} isExternal icon={<Rss01 />} aria-label="Open RSS" />
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Heading size="md">Episodes</Heading>
|
||||
{episodes.map((episode, i) => (
|
||||
<EpisodeCard key={getXPathString(episode, "guid", true) || i} episode={episode} />
|
||||
))}
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PodcastView() {
|
||||
const { guid } = useParams();
|
||||
const [search] = useSearchParams();
|
||||
const url = search.get("feed") || search.get("url");
|
||||
|
||||
if (!guid || !url) return <Navigate to="/podcasts" />;
|
||||
|
||||
const pointer: FeedPointer = {
|
||||
guid,
|
||||
url: new URL(url),
|
||||
};
|
||||
|
||||
const { xml, loading, error } = useFeedXML(url, true);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<AlertTitle>Error!</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
if (loading || !xml)
|
||||
return (
|
||||
<Alert status="info">
|
||||
<Spinner mr="4" />
|
||||
<Box>
|
||||
<AlertTitle>Loading...</AlertTitle>
|
||||
<AlertDescription>{url}</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return <PodcastPage pointer={pointer} xml={xml} />;
|
||||
}
|
@ -85,11 +85,11 @@ export default function PostSettings() {
|
||||
|
||||
<FormControl>
|
||||
<Flex alignItems="center">
|
||||
<FormLabel htmlFor="autoShowMedia" mb="0">
|
||||
<FormLabel htmlFor="addClientTag" mb="0">
|
||||
Add client tag
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="autoShowMedia"
|
||||
id="addClientTag"
|
||||
isChecked={addClientTag}
|
||||
onChange={() => localSettings.addClientTag.next(!localSettings.addClientTag.value)}
|
||||
/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user