diff --git a/README.md b/README.md index 85c029f..0dd75e0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ This is the official monorepo for Feedbomb. - **Open-source**: Feedbomb thrives from the open-source community. - **Self-Hostable**: Host Feedbomb on your own server. - **PWA Support**: Use Feedbomb as a PWA for a native-like experience on all your devices. -- **Advanced article reader**: Read articles with our advanced, fully-featured article reader for a distraction-free reading experience. Plays nice with YouTube by embedding the video. - **Modern UI**: Feedbomb has a modern, responsive user interface. - **Filter out the bad stuff**: Feedbomb lets you create custom rules to filter out unwanted articles. - **Much more coming soon** diff --git a/apps/web/package.json b/apps/web/package.json index 9f05304..5348182 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "1.0.0", + "dompurify": "^3.1.7", "iconv-lite": "^0.6.3", "jschardet": "^3.1.4", "lucide-react": "^0.447.0", @@ -34,6 +35,7 @@ "react": "^18", "react-dom": "^18", "react-resizable-panels": "^2.1.4", + "rss-parser": "^3.13.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7" }, diff --git a/apps/web/src/app/dashboard/page.jsx b/apps/web/src/app/dashboard/page.jsx deleted file mode 100644 index 5b34acb..0000000 --- a/apps/web/src/app/dashboard/page.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import { AppSidebar } from "@/components/app-sidebar" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" -import { Separator } from "@/components/ui/separator" -import { - SidebarInset, - SidebarProvider, - SidebarTrigger, -} from "@/components/ui/sidebar" - -export default function Page() { - return ( - ( - - -
- - - - - - - Building Your Application - - - - - Data Fetching - - - -
-
-
-
-
-
-
-
-
- - ) - ); -} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 7d4e6e3..b9a9300 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -157,7 +157,7 @@ img { } .dark .custom-scrollbar { scrollbar-width: normal; - scrollbar-color: #525252 black !important; + scrollbar-color: #525252 hsl(var(--background)) !important; } .custom-scrollbar::-webkit-scrollbar { diff --git a/apps/web/src/app/page.jsx b/apps/web/src/app/page.jsx index fd1439a..715bebd 100644 --- a/apps/web/src/app/page.jsx +++ b/apps/web/src/app/page.jsx @@ -1,5 +1,14 @@ "use client"; import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ModeToggle } from "@/components/ui/dark-toggle"; +import { SettingsIcon, LoaderCircle } from "lucide-react"; +import { CommandPalette } from "@/components/ui/cmd"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; import { Dialog, DialogContent, @@ -9,20 +18,6 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { ModeToggle } from "@/components/ui/dark-toggle"; -import { - SettingsIcon, - LucideHome, - LucidePlus, - LoaderCircle, -} from "lucide-react"; -import { CommandPalette } from "@/components/ui/cmd"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; import { AppSidebar } from "@/components/app-sidebar"; import { SidebarContent, @@ -35,6 +30,11 @@ import { SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; +import Parser from "rss-parser"; +import DOMPurify from "dompurify"; +import { ShareOptions } from "@/components/ui/share-options"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; export default function Home() { const [feedsJSON, setFeedsJSON] = useState([]); @@ -42,124 +42,28 @@ export default function Home() { const [newFeedURL, setNewFeedURL] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [selector, setSelector] = useState("all_posts"); - const [currentIndex, setCurrentIndex] = useState(0); - const [sidebarOpen, setSidebarOpen] = useState(false); const [pwaCardShowing, setPwaCardShowing] = useState(false); const [deferredPrompt, setDeferredPrompt] = useState(null); const [rules, setRules] = useState([]); const [rendered, setRendered] = useState(false); const [feeds, setFeeds] = useState([]); - const [selectedArticleURL, setSelectedArticleURL] = useState(null); - const [iframeLoaded, setIframeLoaded] = useState(false); - const [panelBalance, setPanelBalance] = useState(60); + const panelBalance = 60; + const [selectedArticle, setSelectedArticle] = useState(null); const openDialog = () => { setDialogOpen(true); }; async function parseRSSFeed(xmlString, url) { - let feed = {}; - let feedItems = []; - try { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlString, "application/xml"); - console.log(xmlDoc); - let feedTitle = xmlDoc.querySelector("title") - ? xmlDoc.querySelector("title").textContent - : ""; - - let feedIcon = xmlDoc.querySelector("link") - ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent( - xmlDoc.querySelector("link")?.getAttribute("href")?.split("/")[2] || - url.split("/")[2] - )}&sz=32` - : `https://www.google.com/s2/favicons?domain=${encodeURIComponent( - url.split("/")[2] - )}&sz=32`; - feed.title = feedTitle; - feed.feedURL = url; - feed.icon = feedIcon; - feed.type = "feeds"; - - let items = - xmlDoc.querySelectorAll("item, entry") || - xmlDoc - .querySelector("rss") - .querySelector("channel") - .querySelectorAll("item, entry"); - setFeeds((prev) => [ - ...prev, - { - title: feedTitle, - feedURL: url, - icon: feedIcon, - type: "feeds", - }, - ]); - items.forEach((item) => { - const title = item.querySelector("title") - ? item.querySelector("title").textContent - : ""; - const linkElement = item.querySelector("link"); - const content = - item.getElementsByTagName("content:encoded")[0] || - item.querySelector("content") || - item.querySelector("description"); - const link = linkElement - ? linkElement.getAttribute("href") || linkElement.textContent - : "#"; - const description = item.querySelector("description, summary") - ? item.querySelector("description, summary").textContent - : ""; - - let pubDate = item.querySelector("pubDate, published") - ? item.querySelector("pubDate, published").textContent - : ""; - let dateObj = new Date(pubDate); - if (isNaN(dateObj.getTime())) dateObj = new Date(); - function stripHTML(input) { - return input.replace(/<\/?[^>]+(>|$)/g, ""); - } - let authorElement = item.querySelector("author"); - let dcCreatorElement = item.getElementsByTagName("dc:creator")[0]; - let author = authorElement - ? authorElement.textContent || - authorElement.querySelector("name")?.textContent || - "" - : dcCreatorElement - ? dcCreatorElement.textContent - : ""; - - feedItems.push({ - title: title, - link: link, - description: stripHTML(content.textContent), - pubDate: dateObj.toISOString(), - author: author, - feedURL: url, - }); - }); - } catch (error) { - console.error("Error parsing RSS feed:", error); - } - feed.items = feedItems; + const parser = new Parser(); + const feed = await parser.parseString(xmlString); + feed.feedId = feed.link; + feed.items.forEach((item) => { + item.feedId = feed.link; + }); return feed; } - function decodeHtmlEntities(str) { - const entities = { - "<": "<", - ">": ">", - "&": "&", - """: '"', - "'": "'", - }; - return str.replace( - /&(lt|gt|amp|quot|#39);/g, - (match, p1) => entities[match] || match - ); - } - async function fetchFeeds(urls) { try { fetch("/api/fetchFeeds", { @@ -175,6 +79,7 @@ export default function Home() { let xml = data[i].xml; if (xml != null) { let feed = await parseRSSFeed(xml, data[i].url); + setFeeds((prev) => [...prev, feed]); setFeedsJSON((prev) => [...prev, feed]); } } @@ -210,21 +115,6 @@ export default function Home() { } } - function decodeHtmlEntities(str) { - const entities = { - "<": "<", - ">": ">", - "&": "&", - """: '"', - "'": "'", - }; - - return str.replace( - /&(lt|gt|amp|quot|#39);/g, - (match, p1) => entities[match] || match - ); - } - const handleAddFeed = () => { let validatedFeedURL = newFeedURL.trim(); if (!validatedFeedURL.startsWith("http")) { @@ -363,20 +253,58 @@ export default function Home() { Home {feeds.map((feed, index) => { - if (feed.type != "feeds") return null; return ( setSelector(feed.feedURL)} + onClick={() => setSelector(feed.feedId)} > {feed.title} ); })} + + + + openDialog()} + className="select-none cursor-pointer" + > + Add feed + + + + + + Add New RSS Feed + + Enter the URL of the RSS feed you want to add. + + +
+
+ + setNewFeedURL(e.target.value)} + className="col-span-3" + placeholder="https://example.com/rss" + /> +
+
+ Discover more feeds + + + +
+
@@ -432,7 +360,7 @@ export default function Home() { return check; } else { return ( - item.feedURL === selector && + item.feedId === selector && checkArticle(item.title, item.author) ); } @@ -440,16 +368,11 @@ export default function Home() { .map((item, index) => ( @@ -494,26 +413,33 @@ export default function Home() { defaultSize={panelBalance} className="max-sm:hidden" > -
- {selectedArticleURL != null && ( - <> - {!iframeLoaded ? ( -
- {selectedArticleURL != null ? ( -
- -
- ) : null} -
- ) : null} - - + > +
)}
diff --git a/apps/web/src/app/read/[articleUrl]/page.jsx b/apps/web/src/app/read/[articleUrl]/page.jsx index db4af25..f6089a4 100644 --- a/apps/web/src/app/read/[articleUrl]/page.jsx +++ b/apps/web/src/app/read/[articleUrl]/page.jsx @@ -1,208 +1,9 @@ -import BackButton from "@/components/ui/back-button"; -import { Button } from "@/components/ui/button"; -import { ModeToggle } from "@/components/ui/dark-toggle"; -import { ShareOptions } from "@/components/ui/share-options"; -import { extract } from "@extractus/article-extractor"; -import { SettingsIcon } from "lucide-react"; +import { redirect } from "next/navigation"; const ArticlePage = async ({ params, searchParams }) => { - let decodedUrl; - try { - let content = ""; - let title = ""; - let datePublished = ""; - let author = ""; - let error = ""; - let isLoading = true; - let ttr = 0; - let image; - let isYouTubeVideo = false; - let youtubeVideoId = ""; - const { articleUrl } = params; - const src = searchParams.src || "reader"; - decodedUrl = atob(decodeURIComponent(articleUrl.replaceAll("-", "/"))); - - if (!decodedUrl.startsWith("http")) { - decodedUrl = "https://" + decodedUrl; - } - - const youtubeRegex = - /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=)?(.+)/; - const match = decodedUrl.match(youtubeRegex); - if (match) { - isYouTubeVideo = true; - youtubeVideoId = match[1]; - } - - if (!isYouTubeVideo) { - const article = await extract(decodedUrl); - title = article.title; - datePublished = article.published; - author = article.author; - content = article.content; - ttr = article.ttr; - image = article.image; - console.log(article); - } else { - title = "Video"; - } - isLoading = false; - - content.replaceAll(" - - {title} - - - - - - - - - - - {src == "reader" && ( -
-
- -
-
- - - - - -
-
- )} -
- {isLoading ? ( -

- Loading... -

- ) : error ? ( - <> - We're sorry, but we couldn't process this article. -
-
- - - ) : ( - <> -

{title}

- {!isYouTubeVideo && ( -

- - By {typeof author === "string" ? author : author.name} - {" "} - | {new Date(datePublished).toLocaleString()} - {ttr > 60 ? ` | ${Math.floor(ttr / 60)} min read` : ""} -

- )} - -
-
- {isYouTubeVideo ? ( -
- -
- ) : ( -
- )} - - )} -
- - - ); - } catch (err) { - return ( - <> - - Couldn't process article - - - - - - -
- We're sorry, but we couldn't process this article. -
-
-
- - - - {src == "reader" && ( - - - - )} -
-
- - ); - } + const { articleUrl } = params; + const decodedUrl = atob(decodeURIComponent(articleUrl.replaceAll("-", "/"))); + redirect(decodedUrl); }; export default ArticlePage; diff --git a/apps/web/src/app/read/layout.js b/apps/web/src/app/read/layout.js deleted file mode 100644 index ff84328..0000000 --- a/apps/web/src/app/read/layout.js +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; -import { ThemeProvider } from "@/components/ui/theme-provider"; -import { usePathname } from "next/navigation"; -import { CommandPalette } from "@/components/ui/cmd"; - -export default function RootLayout({ children }) { - const pathname = usePathname(); - return ( - - - - - {children} - - - - ); -} diff --git a/apps/web/src/app/reader/page.jsx b/apps/web/src/app/reader/page.jsx deleted file mode 100644 index 5620091..0000000 --- a/apps/web/src/app/reader/page.jsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { CommandPalette } from "@/components/ui/cmd"; -import { ModeToggle } from "@/components/ui/dark-toggle"; -import { Input } from "@/components/ui/input"; -import { SettingsIcon } from "lucide-react"; -import { ChevronLeft } from "lucide-react"; -import { useState } from "react"; - -export default function Read() { - const [articleUrl, setArticleUrl] = useState(""); - return ( - <> -
-
- -
-
- - - - - -
-
-
-
-
-

The best way to read articles online

-

- Feedbomb Reader is a modern, distraction-free reading experience - for the modern web. Blocks ads, pop-ups, distractions, and more. -

-
-
- setArticleUrl(e.target.value)} - onKeyDown={(e) => { - if (e.key == "Enter" && articleUrl.length > 0) { - window.location.href = `/read/${btoa(articleUrl).replaceAll( - "/", - "-" - )}`; - } - }} - /> - - - -
-
-
-
- - ); -} diff --git a/apps/web/src/components/ui/cmd.jsx b/apps/web/src/components/ui/cmd.jsx index 9f20e60..4de9e61 100644 --- a/apps/web/src/components/ui/cmd.jsx +++ b/apps/web/src/components/ui/cmd.jsx @@ -3,22 +3,18 @@ import { useEffect } from "react"; const { MagnifyingGlassIcon } = require("@radix-ui/react-icons"); import { - Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, - CommandSeparator, - CommandShortcut, } from "@/components/ui/command"; import { Home } from "lucide-react"; const { Sun } = require("lucide-react"); const { Moon } = require("lucide-react"); const { Computer } = require("lucide-react"); const { PlusIcon } = require("lucide-react"); -const { BookOpen } = require("lucide-react"); const { useTheme } = require("next-themes"); const { useState } = require("react"); @@ -50,9 +46,6 @@ export function CommandPalette({ onAddFeed }) { case "discover": window.location.href = "/discover"; break; - case "reader": - window.location.href = "/reader"; - break; case "add-feed": onAddFeed(); break; @@ -85,10 +78,6 @@ export function CommandPalette({ onAddFeed }) { Settings - handleCommand("reader")}> - - Reader - handleCommand("discover")}> Discover diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64e7bf6..d720abc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: cmdk: specifier: 1.0.0 version: 1.0.0(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + dompurify: + specifier: ^3.1.7 + version: 3.1.7 iconv-lite: specifier: ^0.6.3 version: 0.6.3 @@ -95,6 +98,9 @@ importers: react-resizable-panels: specifier: ^2.1.4 version: 2.1.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + rss-parser: + specifier: ^3.13.0 + version: 3.13.0 tailwind-merge: specifier: ^2.5.2 version: 2.5.4 @@ -985,6 +991,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + domutils@3.1.0: resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} @@ -997,6 +1006,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1368,6 +1380,9 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rss-parser@3.13.0: + resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1377,6 +1392,9 @@ packages: sanitize-html@2.13.1: resolution: {integrity: sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -1555,6 +1573,14 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + yaml@2.6.0: resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} engines: {node: '>= 14'} @@ -2373,6 +2399,8 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.1.7: {} + domutils@3.1.0: dependencies: dom-serializer: 2.0.0 @@ -2385,6 +2413,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@2.2.0: {} + entities@4.5.0: {} escape-string-regexp@4.0.0: {} @@ -2727,6 +2757,11 @@ snapshots: reusify@1.0.4: {} + rss-parser@3.13.0: + dependencies: + entities: 2.2.0 + xml2js: 0.5.0 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -2742,6 +2777,8 @@ snapshots: parse-srcset: 1.0.2 postcss: 8.4.47 + sax@1.4.1: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -2917,4 +2954,11 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + xml2js@0.5.0: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + yaml@2.6.0: {}