feat: added youttube support
b3a4001b
6 file(s) · +206 −12
| 135 | 135 | publishedDate: extractPostDate(post), |
|
| 136 | 136 | link: sanitizedPost.link, |
|
| 137 | 137 | feedId: result.value.id, |
|
| 138 | - | content: extractPostContent(post), |
|
| 138 | + | content: extractPostContent(post, sanitizedPost.link), |
|
| 139 | 139 | }); |
|
| 140 | 140 | } |
|
| 141 | 141 | ||
| 254 | 254 | publishedDate: extractPostDate(post), |
|
| 255 | 255 | link: sanitizedPost.link, |
|
| 256 | 256 | feedId: result.value.id, |
|
| 257 | - | content: extractPostContent(post), |
|
| 257 | + | content: extractPostContent(post, sanitizedPost.link), |
|
| 258 | 258 | }); |
|
| 259 | 259 | } |
|
| 260 | 260 | ||
| 22 | 22 | extractPostDate, |
|
| 23 | 23 | sanitizeFeedData, |
|
| 24 | 24 | sanitizePostData, |
|
| 25 | + | isYouTubeUrl, |
|
| 26 | + | convertYouTubeUrlToFeed, |
|
| 25 | 27 | } from "@/lib/feed-operations"; |
|
| 26 | 28 | ||
| 27 | 29 | interface AddFeedDialogProps { |
|
| 51 | 53 | let feedUrl = urlInput; |
|
| 52 | 54 | let xmlData: string | null = null; |
|
| 53 | 55 | ||
| 54 | - | if (!looksLikeFeedUrl(urlInput)) { |
|
| 56 | + | // Check if it's a YouTube URL and convert it |
|
| 57 | + | if (isYouTubeUrl(urlInput)) { |
|
| 58 | + | setStatusMessage("Detecting YouTube channel..."); |
|
| 59 | + | const youtubeFeedUrl = await convertYouTubeUrlToFeed(urlInput); |
|
| 60 | + | ||
| 61 | + | if (!youtubeFeedUrl) { |
|
| 62 | + | setStatusMessage( |
|
| 63 | + | "Could not extract YouTube channel ID. Please try a direct channel URL.", |
|
| 64 | + | ); |
|
| 65 | + | setIsAddingFeed(false); |
|
| 66 | + | return; |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | feedUrl = youtubeFeedUrl; |
|
| 70 | + | xmlData = await fetchFeedWithFallback(feedUrl); |
|
| 71 | + | } else if (!looksLikeFeedUrl(urlInput)) { |
|
| 72 | + | setStatusMessage("Discovering RSS feed..."); |
|
| 55 | 73 | const discovered = await discoverFeed(urlInput); |
|
| 56 | 74 | ||
| 57 | 75 | if (!discovered) { |
|
| 98 | 116 | publishedDate: extractPostDate(post), |
|
| 99 | 117 | link: sanitizedPost.link, |
|
| 100 | 118 | feedId: result.value.id, |
|
| 101 | - | content: extractPostContent(post), |
|
| 119 | + | content: extractPostContent(post, sanitizedPost.link), |
|
| 102 | 120 | }); |
|
| 103 | 121 | } |
|
| 104 | 122 | ||
| 325 | 325 | publishedDate: extractPostDate(post), |
|
| 326 | 326 | link: postLink, |
|
| 327 | 327 | feedId: feed.id, |
|
| 328 | - | content: extractPostContent(post), |
|
| 328 | + | content: extractPostContent(post, postLink), |
|
| 329 | 329 | }); |
|
| 330 | 330 | newPostsCount++; |
|
| 331 | 331 | } |
| 29 | 29 | import rehypeRaw from "rehype-raw"; |
|
| 30 | 30 | import remarkGfm from "remark-gfm"; |
|
| 31 | 31 | import { toast } from "sonner"; |
|
| 32 | + | import { extractYouTubeVideoId, isYouTubePost } from "@/lib/feed-operations"; |
|
| 32 | 33 | ||
| 33 | 34 | function Dashboard() { |
|
| 34 | 35 | const [selectedFeedId, setSelectedFeedId] = React.useState<string | null>( |
|
| 67 | 68 | const selectedPost = selectedPostId |
|
| 68 | 69 | ? allPosts.find((p) => p.id === selectedPostId) |
|
| 69 | 70 | : null; |
|
| 71 | + | ||
| 72 | + | // Get the feed for the selected post to check if it's YouTube |
|
| 73 | + | const selectedPostFeed = React.useMemo(() => { |
|
| 74 | + | if (!selectedPost?.feedId) return null; |
|
| 75 | + | return allFeeds.find((f) => f.id === selectedPost.feedId); |
|
| 76 | + | }, [selectedPost?.feedId, allFeeds]); |
|
| 70 | 77 | ||
| 71 | 78 | // Check if current post is read |
|
| 72 | 79 | const isCurrentPostRead = React.useMemo(() => { |
|
| 249 | 256 | </span> |
|
| 250 | 257 | </div> |
|
| 251 | 258 | <Separator /> |
|
| 259 | + | {/* YouTube Embed - show if this is a YouTube video */} |
|
| 260 | + | {selectedPostFeed && |
|
| 261 | + | isYouTubePost(selectedPostFeed.feedUrl) && |
|
| 262 | + | selectedPost.link && |
|
| 263 | + | (() => { |
|
| 264 | + | const videoId = extractYouTubeVideoId(selectedPost.link); |
|
| 265 | + | if (videoId) { |
|
| 266 | + | return ( |
|
| 267 | + | <div className="w-full mb-6"> |
|
| 268 | + | <div |
|
| 269 | + | className="relative w-full" |
|
| 270 | + | style={{ paddingBottom: "56.25%" }} |
|
| 271 | + | > |
|
| 272 | + | <iframe |
|
| 273 | + | className="absolute top-0 left-0 w-full h-full rounded-lg" |
|
| 274 | + | src={`https://www.youtube.com/embed/${videoId}`} |
|
| 275 | + | title={selectedPost.title} |
|
| 276 | + | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" |
|
| 277 | + | allowFullScreen |
|
| 278 | + | /> |
|
| 279 | + | </div> |
|
| 280 | + | </div> |
|
| 281 | + | ); |
|
| 282 | + | } |
|
| 283 | + | return null; |
|
| 284 | + | })()} |
|
| 252 | 285 | {selectedPost.content ? ( |
|
| 253 | 286 | <div className="prose prose-gray dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-foreground prose-a:text-primary prose-strong:text-foreground prose-code:text-foreground prose-pre:bg-muted prose-blockquote:text-muted-foreground prose-li:text-foreground space-y-4"> |
|
| 254 | 287 | <ReactMarkdown |
|
| 129 | 129 | } |
|
| 130 | 130 | ||
| 131 | 131 | for (const post of posts) { |
|
| 132 | + | const postLink = extractPostLink(post, isAtom); |
|
| 132 | 133 | evolu.insert("rssPost", { |
|
| 133 | 134 | title: post.title, |
|
| 134 | 135 | author: extractPostAuthor(post, isAtom, feedData.title), |
|
| 135 | 136 | feedTitle: feed.title, |
|
| 136 | 137 | publishedDate: extractPostDate(post), |
|
| 137 | - | link: extractPostLink(post, isAtom), |
|
| 138 | + | link: postLink, |
|
| 138 | 139 | feedId: result.value.id, |
|
| 139 | - | content: extractPostContent(post), |
|
| 140 | + | content: extractPostContent(post, postLink), |
|
| 140 | 141 | }); |
|
| 141 | 142 | } |
|
| 142 | 143 |
| 139 | 139 | } |
|
| 140 | 140 | ||
| 141 | 141 | /** |
|
| 142 | + | * Extracts YouTube channel ID from various YouTube URL formats |
|
| 143 | + | * Supports: |
|
| 144 | + | * - https://www.youtube.com/@ChannelHandle |
|
| 145 | + | * - https://www.youtube.com/channel/UC... |
|
| 146 | + | * - https://www.youtube.com/c/ChannelName |
|
| 147 | + | * - https://www.youtube.com/user/Username |
|
| 148 | + | */ |
|
| 149 | + | export async function extractYouTubeChannelId( |
|
| 150 | + | url: string, |
|
| 151 | + | ): Promise<string | null> { |
|
| 152 | + | try { |
|
| 153 | + | const urlObj = new URL(url); |
|
| 154 | + | ||
| 155 | + | // Direct channel ID format |
|
| 156 | + | if (url.includes("/channel/")) { |
|
| 157 | + | const match = url.match(/\/channel\/([^/?]+)/); |
|
| 158 | + | return match ? match[1] : null; |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | // Handle @ format - need to fetch the page to get channel ID |
|
| 162 | + | if (url.includes("/@")) { |
|
| 163 | + | const handle = url.match(/\/@([^/?]+)/)?.[1]; |
|
| 164 | + | if (!handle) return null; |
|
| 165 | + | ||
| 166 | + | // Fetch the YouTube page to extract the channel ID from meta tags |
|
| 167 | + | try { |
|
| 168 | + | const response = await fetch( |
|
| 169 | + | `https://corsproxy.io/?url=${encodeURIComponent(url)}`, |
|
| 170 | + | ); |
|
| 171 | + | const html = await response.text(); |
|
| 172 | + | ||
| 173 | + | // Look for channel ID in various places |
|
| 174 | + | const channelIdMatch = html.match(/channelId":"([^"]+)"/); |
|
| 175 | + | if (channelIdMatch) { |
|
| 176 | + | return channelIdMatch[1]; |
|
| 177 | + | } |
|
| 178 | + | ||
| 179 | + | // Alternative: look in meta tags |
|
| 180 | + | const metaMatch = html.match( |
|
| 181 | + | /<meta itemprop="channelId" content="([^"]+)">/, |
|
| 182 | + | ); |
|
| 183 | + | if (metaMatch) { |
|
| 184 | + | return metaMatch[1]; |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | // Alternative: look in link tags |
|
| 188 | + | const linkMatch = html.match( |
|
| 189 | + | /<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/([^"]+)">/, |
|
| 190 | + | ); |
|
| 191 | + | if (linkMatch) { |
|
| 192 | + | return linkMatch[1]; |
|
| 193 | + | } |
|
| 194 | + | } catch (error) { |
|
| 195 | + | console.error("Failed to fetch YouTube page for channel ID:", error); |
|
| 196 | + | return null; |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | // For /c/ and /user/ formats, we also need to fetch the page |
|
| 201 | + | if (url.includes("/c/") || url.includes("/user/")) { |
|
| 202 | + | try { |
|
| 203 | + | const response = await fetch( |
|
| 204 | + | `https://corsproxy.io/?url=${encodeURIComponent(url)}`, |
|
| 205 | + | ); |
|
| 206 | + | const html = await response.text(); |
|
| 207 | + | ||
| 208 | + | const channelIdMatch = html.match(/channelId":"([^"]+)"/); |
|
| 209 | + | if (channelIdMatch) { |
|
| 210 | + | return channelIdMatch[1]; |
|
| 211 | + | } |
|
| 212 | + | } catch (error) { |
|
| 213 | + | console.error("Failed to fetch YouTube page for channel ID:", error); |
|
| 214 | + | return null; |
|
| 215 | + | } |
|
| 216 | + | } |
|
| 217 | + | ||
| 218 | + | return null; |
|
| 219 | + | } catch (error) { |
|
| 220 | + | console.error("Error extracting YouTube channel ID:", error); |
|
| 221 | + | return null; |
|
| 222 | + | } |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /** |
|
| 226 | + | * Converts YouTube channel URL to RSS feed URL |
|
| 227 | + | */ |
|
| 228 | + | export async function convertYouTubeUrlToFeed( |
|
| 229 | + | url: string, |
|
| 230 | + | ): Promise<string | null> { |
|
| 231 | + | const channelId = await extractYouTubeChannelId(url); |
|
| 232 | + | if (!channelId) return null; |
|
| 233 | + | ||
| 234 | + | return `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | /** |
|
| 238 | + | * Checks if a URL is a YouTube URL |
|
| 239 | + | */ |
|
| 240 | + | export function isYouTubeUrl(url: string): boolean { |
|
| 241 | + | return url.includes("youtube.com") || url.includes("youtu.be"); |
|
| 242 | + | } |
|
| 243 | + | ||
| 244 | + | /** |
|
| 245 | + | * Extracts YouTube video ID from a video URL |
|
| 246 | + | * Supports: |
|
| 247 | + | * - https://www.youtube.com/watch?v=VIDEO_ID |
|
| 248 | + | * - https://youtu.be/VIDEO_ID |
|
| 249 | + | * - https://www.youtube.com/embed/VIDEO_ID |
|
| 250 | + | */ |
|
| 251 | + | export function extractYouTubeVideoId(url: string): string | null { |
|
| 252 | + | try { |
|
| 253 | + | // Standard watch URL |
|
| 254 | + | const watchMatch = url.match(/[?&]v=([^&]+)/); |
|
| 255 | + | if (watchMatch) return watchMatch[1]; |
|
| 256 | + | ||
| 257 | + | // Short URL format |
|
| 258 | + | const shortMatch = url.match(/youtu\.be\/([^?]+)/); |
|
| 259 | + | if (shortMatch) return shortMatch[1]; |
|
| 260 | + | ||
| 261 | + | // Embed URL format |
|
| 262 | + | const embedMatch = url.match(/youtube\.com\/embed\/([^?]+)/); |
|
| 263 | + | if (embedMatch) return embedMatch[1]; |
|
| 264 | + | ||
| 265 | + | return null; |
|
| 266 | + | } catch { |
|
| 267 | + | return null; |
|
| 268 | + | } |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /** |
|
| 272 | + | * Checks if a post is from a YouTube feed |
|
| 273 | + | */ |
|
| 274 | + | export function isYouTubePost(feedUrl: string | null): boolean { |
|
| 275 | + | if (!feedUrl) return false; |
|
| 276 | + | return feedUrl.includes("youtube.com/feeds/videos.xml"); |
|
| 277 | + | } |
|
| 278 | + | ||
| 279 | + | /** |
|
| 142 | 280 | * Extracts post link from RSS or Atom post entry |
|
| 143 | 281 | */ |
|
| 144 | 282 | export function extractPostLink(post: any, isAtom: boolean): string { |
|
| 204 | 342 | /** |
|
| 205 | 343 | * Extracts content from RSS or Atom post entry |
|
| 206 | 344 | */ |
|
| 207 | - | export function extractPostContent(post: any): string { |
|
| 345 | + | export function extractPostContent(post: any, postLink?: string): string { |
|
| 208 | 346 | // Try various content fields in order of preference |
|
| 209 | 347 | const content = |
|
| 210 | 348 | post["content:encoded"] || post.content || post.description || post.summary; |
|
| 349 | + | ||
| 350 | + | // Default fallback message |
|
| 351 | + | const fallbackMessage = postLink |
|
| 352 | + | ? `<p><a href="${postLink}" target="_blank" rel="noopener noreferrer">View post</a></p>` |
|
| 353 | + | : "Please open on the web"; |
|
| 211 | 354 | ||
| 212 | 355 | // Handle different content structures |
|
| 213 | 356 | if (typeof content === "string") { |
|
| 214 | 357 | const trimmed = content.trim(); |
|
| 215 | - | // If content is too short or empty, return default message |
|
| 216 | - | return trimmed.length > 0 ? trimmed : "Please open on the web"; |
|
| 358 | + | return trimmed.length > 0 ? trimmed : fallbackMessage; |
|
| 217 | 359 | } else if (content && typeof content === "object") { |
|
| 218 | 360 | // Handle CDATA or nested text |
|
| 219 | 361 | const extracted = content.__cdata || content["#text"] || ""; |
|
| 220 | 362 | const trimmed = String(extracted).trim(); |
|
| 221 | - | return trimmed.length > 0 ? trimmed : "Please open on the web"; |
|
| 363 | + | return trimmed.length > 0 ? trimmed : fallbackMessage; |
|
| 222 | 364 | } |
|
| 223 | 365 | ||
| 224 | 366 | // No content found - this is fine for link-only feeds |
|
| 225 | - | return "Please open on the web"; |
|
| 367 | + | return fallbackMessage; |
|
| 226 | 368 | } |
|
| 227 | 369 | ||
| 228 | 370 | /** |
|