Add notifications feed

This commit is contained in:
Pas 2025-08-01 20:05:07 -06:00
parent 78af5d76b8
commit 6d362294c4
5 changed files with 878 additions and 0 deletions

124
public/notifications.xml Normal file
View file

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>P-Stream Notifications</title>
<link>https://pstream.mov</link>
<description>Site updates and important notifications for P-Stream users</description>
<language>en</language>
<lastBuildDate>Mon, 28 Jul 2025 21:53:00 GMT</lastBuildDate>
<atom:link href="https://pstream.mov/notifications.xml" rel="self" type="application/rss+xml" />
<item>
<guid>notification-8-1-25</guid>
<title>Welcome to the P-Stream Beta!</title>
<description>P-Stream is now in beta! This is a test of the new notification system.
You can now receive notifications for new updates and important announcements.
You can also view the notifications in the notifications page.</description>
<pubDate>Fri, 01 Aug 2025 21:00:00 GMT</pubDate>
<category>announcement</category>
</item>
<item>
<guid>notification-028</guid>
<title>P-Stream v5.0.4 released!</title>
<description>A bunch more quality-of-life improvements and fixes have been released.
- Accounts are no longer required for migration downloads and uploads.
- Right-click to open the details modal has been re-added.
- Increased hold-to-edit time for media cards.
- Re-added jiggle physics while editing.
- Custom passphrase support.
- Improved subtitle selection experience.
- Bug fixes and behind-the-scenes changes.
A few new sources have also been added, including Flixer and VidSrc.vip.</description>
<pubDate>Tue, 15 Jul 2025 20:58:00 GMT</pubDate>
<category>update</category>
</item>
<item>
<guid>notification-026</guid>
<title>New Domain: pstream.mov</title>
<link>https://pstream.mov</link>
<description>The new domain is https://pstream.mov/
Most pages should now be working. If you had an account on the old site, your data will be there. If you did not have an account, you can download your data from pstream.org and upload it to the new site.</description>
<pubDate>Mon, 14 Jul 2025 17:30:00 GMT</pubDate>
<category>announcement</category>
</item>
<item>
<guid>notification-022</guid>
<title>P-Stream v5.0.2 released!</title>
<description>This is another minor update with great quality-of-life features.
- Added "open in..." button to the download menu.
- Added the ability to set a custom playback speed.
- Added buttons to mark episodes as watched or unwatched.
- Added custom color pickers to account styles and more user icons.
- Estimate quality for non-standard resolution sources.
- Re-added email to the DMCA page.
- Added an autoplay toggle to playback settings.
- Fixed end time to account for playback speed.
- Updated some translations and fixed many bugs.</description>
<pubDate>Mon, 07 Jul 2025 19:55:00 GMT</pubDate>
<category>update</category>
</item>
<item>
<guid>notification-008</guid>
<title>P-Stream v5.0.1</title>
<description>Some minor updates this time:
- Added network images to the details modal.
- Added a letterboxd/trakt list page: "Discover All Lists."
- Fixed audio description not displaying.
- Automatically open the settings menu when failing to play.
- Added a compact episodes view.
- Updated the "About" page.
- Added low-performance/bandwidth mode.
- The share button on the details modal now opens the iOS share sheet.
- Fixed various bugs.</description>
<pubDate>Tue, 24 Jun 2025 18:20:00 GMT</pubDate>
<category>update</category>
</item>
<item>
<guid>notification-003-2</guid>
<title>New Movie Lists Page</title>
<link>https://pstream.mov/discover/all</link>
<description>A new page has been added to discover many movie lists powered by Letterboxd. A couple of minor bugs were also fixed.</description>
<pubDate>Sat, 07 Jun 2025 18:41:00 GMT</pubDate>
<category>update</category>
</item>
<item>
<guid>notification-005-2</guid>
<title>5.0.0 Released!</title>
<description>This release is the largest facelift to the project yet, with many new features and fixes.
New Features
- Discover Page Overhaul: The Discover page has been completely rewritten.
- New Trakt Sections: Explore new "Latest Releases" and "Top 4K" sections.
- Featured Movies/Shows Carousel: A new slideshow of new and popular movies or shows is now on the Discover page.
- Quality Indicators: See the video quality (HD or CAM) on the Details Modal or Featured Carousel.
- Expanded Descriptions: Episode descriptions can now be expanded.
- Watchparty (Beta)! Easily invite anyone to watch with you. Now joinable from the home page.
- You can now click on actors and directors on a movie or show's detail page to explore their other work.
Improvements
- (Most) User preferences now sync.
- UI &amp; UX Refresh: New player settings design and an overhauled Details Modal.
- You can now toggle Carousel View to show "Currently Watching" and "Bookmarked" lists as carousels.
Bug Fixes
- Fixed several issues with loading skeletons.
- Implemented a fix to better handle subtitle files.
- Fixed various layout issues.
- Febbox Token now actually syncs with your account.
- Many other bugs.</description>
<pubDate>Fri, 06 Jun 2025 11:29:00 GMT</pubDate>
<category>update</category>
</item>
</channel>
</rss>

View file

@ -79,6 +79,8 @@ export enum Icons {
TMDB = "tmdb",
IMDB = "imdb",
EAR = "ear",
BELL = "bell",
RELOAD = "reload",
}
export interface IconProps {
@ -175,6 +177,8 @@ const iconList: Record<Icons, string> = {
tmdb: `<svg width="2em" height="2em" fill="currentColor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>`,
imdb: `<svg width="2em" height="2em" fill="currentColor" viewBox="0 0 32 32" id="Camada_1" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g> <path d="M8.4,21.1H5.9V9.9h3.8l0.7,4.7h0.1L11,9.9h3.8v11.2h-2.5v-6.7h-0.1l-0.9,6.7H9.4l-1-6.7h0L8.4,21.1L8.4,21.1z"></path> <path d="M15.8,9.8c0.4,0,3.2-0.1,4.7,0.1c1.2,0.1,1.8,1.1,1.9,2.3c0.1,2.2,0.1,4.4,0.1,6.6c0,0.2,0,0.5-0.1,0.8 c-0.2,0.9-0.7,1.4-1.9,1.5c-1.5,0.1-3,0.1-4.4,0.1c0,0-0.1,0-0.2,0V9.8z M18.8,11.9v7.2c0.5,0,0.8-0.2,0.8-0.7c0-1.9,0-3.9,0-5.9 C19.6,12,19.4,11.8,18.8,11.9z"></path> <path d="M2,21.1V9.9h2.9v11.2H2z"></path> <path d="M29.9,14.1c-0.1-0.8-0.6-1.2-1.4-1.4c-0.8-0.1-1.6,0-2.3,0.7V9.9h-2.8v11.2H26c0.1-0.2,0.1-0.4,0.2-0.5c0,0,0,0,0.1,0 c0.1,0.1,0.2,0.2,0.3,0.3c0.7,0.5,1.5,0.6,2.3,0.3c0.7-0.3,1-0.9,1-1.6c0-0.8,0.1-1.7,0.1-2.6C30,16,30,15,29.9,14.1L29.9,14.1z M27.1,19.1c0,0.2-0.2,0.4-0.4,0.4s-0.4-0.2-0.4-0.4v-4.3c0-0.2,0.2-0.4,0.4-0.4s0.4,0.2,0.4,0.4V19.1z"></path> </g> </g></svg>`,
ear: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" class="bi bi-ear" viewBox="0 0 16 16"> <path d="M8.5 0A5.5 5.5 0 0 0 3 5.5v7.047a3.453 3.453 0 0 0 6.687 1.212l.51-1.363a4.6 4.6 0 0 1 .67-1.197l2.008-2.581A5.34 5.34 0 0 0 8.66 0zM7 5.5v2.695q.168-.09.332-.192c.327-.208.577-.44.72-.727a.5.5 0 1 1 .895.448c-.256.513-.673.865-1.079 1.123A9 9 0 0 1 7 9.313V11.5a.5.5 0 0 1-1 0v-6a2.5 2.5 0 0 1 5 0V6a.5.5 0 0 1-1 0v-.5a1.5 1.5 0 1 0-3 0"/></svg>`,
bell: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M320 64C302.3 64 288 78.3 288 96L288 99.2C215 114 160 178.6 160 256L160 277.7C160 325.8 143.6 372.5 113.6 410.1L103.8 422.3C98.7 428.6 96 436.4 96 444.5C96 464.1 111.9 480 131.5 480L508.4 480C528 480 543.9 464.1 543.9 444.5C543.9 436.4 541.2 428.6 536.1 422.3L526.3 410.1C496.4 372.5 480 325.8 480 277.7L480 256C480 178.6 425 114 352 99.2L352 96C352 78.3 337.7 64 320 64zM258 528C265.1 555.6 290.2 576 320 576C349.8 576 374.9 555.6 382 528L258 528z"/></svg>`,
reload: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 640" fill="currentColor"><!--!Font Awesome Free v7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M544.1 256L552 256C565.3 256 576 245.3 576 232L576 88C576 78.3 570.2 69.5 561.2 65.8C552.2 62.1 541.9 64.2 535 71L483.3 122.8C439 86.1 382 64 320 64C191 64 84.3 159.4 66.6 283.5C64.1 301 76.2 317.2 93.7 319.7C111.2 322.2 127.4 310 129.9 292.6C143.2 199.5 223.3 128 320 128C364.4 128 405.2 143 437.7 168.3L391 215C384.1 221.9 382.1 232.2 385.8 241.2C389.5 250.2 398.3 256 408 256L544.1 256zM573.5 356.5C576 339 563.8 322.8 546.4 320.3C529 317.8 512.7 330 510.2 347.4C496.9 440.4 416.8 511.9 320.1 511.9C275.7 511.9 234.9 496.9 202.4 471.6L249 425C255.9 418.1 257.9 407.8 254.2 398.8C250.5 389.8 241.7 384 232 384L88 384C74.7 384 64 394.7 64 408L64 552C64 561.7 69.8 570.5 78.8 574.2C87.8 577.9 98.1 575.8 105 569L156.8 517.2C201 553.9 258 576 320 576C449 576 555.7 480.6 573.4 356.5z"/></svg>`,
};
function ChromeCastButton() {

View file

@ -6,6 +6,7 @@ import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { LinksDropdown } from "@/components/LinksDropdown";
import { useNotifications } from "@/components/overlays/NotificationModal";
import { Lightbar } from "@/components/utils/Lightbar";
import { useAuth } from "@/hooks/auth/useAuth";
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
@ -27,6 +28,7 @@ export function Navigation(props: NavigationProps) {
const navigate = useNavigate();
const { loggedIn } = useAuth();
const [scrollPosition, setScrollPosition] = useState(0);
const { openNotifications, getUnreadCount } = useNotifications();
useEffect(() => {
const handleScroll = () => {
@ -182,6 +184,18 @@ export function Navigation(props: NavigationProps) {
/>
</a>
))}
<a
onClick={() => openNotifications()}
rel="noreferrer"
className="text-xl text-white tabbable rounded-full backdrop-blur-lg relative"
>
<IconPatch icon={Icons.BELL} clickable downsized navigation />
{getUnreadCount() > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{getUnreadCount()}
</span>
)}
</a>
</div>
<div className="relative pointer-events-auto">
<LinksDropdown>

View file

@ -0,0 +1,734 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icon, Icons } from "@/components/Icon";
import { Link } from "@/pages/migration/utils";
import { useOverlayStack } from "@/stores/interface/overlayStack";
import { FancyModal } from "./Modal";
const NOTIFICATIONS_ENDPOINT = "/notifications.xml";
interface NotificationItem {
guid: string;
title: string;
link: string;
description: string;
pubDate: string;
category: string;
}
interface NotificationModalProps {
id: string;
}
type ModalView = "list" | "detail";
// Detail view component
function DetailView({
selectedNotification,
goBackToList,
getCategoryColor,
getCategoryLabel,
formatDate,
}: {
selectedNotification: NotificationItem;
goBackToList: () => void;
getCategoryColor: (category: string) => string;
getCategoryLabel: (category: string) => string;
formatDate: (dateString: string) => string;
}) {
return (
<div className="space-y-4">
{/* Header with back button */}
<div className="flex items-center gap-3 pb-4 border-b border-utils-divider">
<button
type="button"
onClick={goBackToList}
className="text-type-link hover:text-type-linkHover transition-colors flex items-center gap-2"
>
<Icon icon={Icons.CHEVRON_LEFT} />
<span>Back to notifications</span>
</button>
</div>
{/* Notification content */}
<div className="space-y-4">
<div className="flex items-center gap-2">
{getCategoryColor(selectedNotification.category) && (
<span
className={`inline-block w-3 h-3 rounded-full ${getCategoryColor(
selectedNotification.category,
)}`}
/>
)}
{getCategoryLabel(selectedNotification.category) && (
<>
<span className="text-sm text-type-secondary">
{getCategoryLabel(selectedNotification.category)}
</span>
<span className="text-sm text-type-secondary"></span>
<span className="text-sm text-type-secondary">
{formatDate(selectedNotification.pubDate)}
</span>
</>
)}
{!getCategoryLabel(selectedNotification.category) && (
<span className="text-sm text-type-secondary">
{formatDate(selectedNotification.pubDate)}
</span>
)}
</div>
<div className="prose prose-invert max-w-none">
<div
className="text-type-secondary leading-relaxed"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: selectedNotification.description
.replace(/\n\n/g, "</p><p>")
.replace(/\n- /g, "</p><p>• ")
.replace(/\n\*\*([^*]+)\*\*/g, "</p><h4>$1</h4><p>")
.replace(/^/, "<p>")
.replace(/$/, "</p>")
.replace(/<p><\/p>/g, "")
.replace(
/<p>• /g,
'<p class="flex items-start gap-2"><span class="text-type-link mt-1">•</span><span>',
)
.replace(/<\/p>/g, "</span></p>"),
}}
/>
</div>
{selectedNotification.link && (
<div className="pt-4 border-t border-utils-divider">
<Link href={selectedNotification.link} target="_blank">
<Icon icon={Icons.LINK} />
<span>Go to page</span>
</Link>
</div>
)}
</div>
</div>
);
}
// List view component
function ListView({
notifications,
readNotifications,
unreadCount,
loading,
error,
containerRef,
markAllAsRead,
markAllAsUnread,
isShiftHeld,
onRefresh,
openNotificationDetail,
getCategoryColor,
getCategoryLabel,
formatDate,
}: {
notifications: NotificationItem[];
readNotifications: Set<string>;
unreadCount: number;
loading: boolean;
error: string | null;
containerRef: React.RefObject<HTMLDivElement>;
markAllAsRead: () => void;
markAllAsUnread: () => void;
isShiftHeld: boolean;
onRefresh: () => void;
openNotificationDetail: (notification: NotificationItem) => void;
getCategoryColor: (category: string) => string;
getCategoryLabel: (category: string) => string;
formatDate: (dateString: string) => string;
}) {
return (
<div className="space-y-4">
{/* Header with refresh and mark all buttons */}
<div className="flex gap-4 items-center pb-4 border-b border-utils-divider">
<span className="text-sm text-type-secondary">
{unreadCount} unread notification{unreadCount !== 1 ? "s" : ""}
</span>
<div className="flex gap-2">
{isShiftHeld ? (
<button
type="button"
onClick={markAllAsUnread}
className="text-sm text-red-400 hover:text-red-300 transition-colors"
>
Mark all as unread
</button>
) : (
unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="text-sm text-type-link hover:text-type-linkHover transition-colors"
>
Mark all as read
</button>
)
)}
</div>
<div className="flex-1 flex justify-end mr-4">
<button
type="button"
onClick={onRefresh}
disabled={loading}
className="text-sm text-type-secondary hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon
icon={Icons.RELOAD}
className={loading ? "animate-spin" : ""}
/>
</button>
</div>
</div>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Icon
icon={Icons.RELOAD}
className="animate-spin rounded-full text-type-secondary text-[2rem]"
/>
<span className="ml-3 text-type-secondary">Loading...</span>
</div>
)}
{/* Error state */}
{error && (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Icon icon={Icons.WARNING} className="text-[2rem] text-red-400" />
<p className="text-red-400 mb-2">Failed to load notifications</p>
<p className="text-sm text-type-secondary">{error}</p>
</div>
)}
{/* Notifications list */}
{!loading && !error && (
<div
ref={containerRef}
className="space-y-4 max-h-[calc(100vh-100px)] md:max-h-[calc(100vh-300px)] overflow-y-auto"
>
{notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Icon
icon={Icons.BELL}
className="text-type-secondary text-[2rem]"
/>
<p className="text-type-secondary">No notifications available</p>
</div>
) : (
notifications.map((notification) => {
const isRead = readNotifications.has(notification.guid);
return (
<div
key={notification.guid}
className={`p-4 rounded-lg border transition-all cursor-pointer hover:bg-background-main/50 mr-2 ${
isRead
? "bg-background-main border-utils-divider opacity-75"
: "bg-background-main border-type-link/70 shadow-sm"
}`}
onClick={() => openNotificationDetail(notification)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3
className={`font-medium ${
isRead ? "text-type-secondary" : "text-white"
}`}
>
{notification.title}
</h3>
{!isRead && (
<span className="inline-block w-2 h-2 rounded-full bg-red-500" />
)}
</div>
<p className="text-sm text-type-secondary mb-2 line-clamp-2">
{notification.description
.replace(/\n/g, " ")
.substring(0, 150)}
{notification.description.length > 150 ? "..." : ""}
</p>
</div>
<div className="flex items-center gap-2">
{getCategoryColor(notification.category) && (
<span
className={`inline-block w-2 h-2 rounded-full ${getCategoryColor(
notification.category,
)}`}
/>
)}
<span className="text-xs text-type-secondary">
{getCategoryLabel(notification.category)}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-type-secondary">
{formatDate(notification.pubDate)}
</span>
<Icon
icon={Icons.CHEVRON_RIGHT}
className="text-type-link"
/>
</div>
</div>
);
})
)}
</div>
)}
</div>
);
}
export function NotificationModal({ id }: NotificationModalProps) {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [readNotifications, setReadNotifications] = useState<Set<string>>(
new Set(),
);
const [currentView, setCurrentView] = useState<ModalView>("list");
const [selectedNotification, setSelectedNotification] =
useState<NotificationItem | null>(null);
const [isShiftHeld, setIsShiftHeld] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Load read notifications from cookie
useEffect(() => {
const savedRead = localStorage.getItem("read-notifications");
if (savedRead) {
try {
const readArray = JSON.parse(savedRead);
setReadNotifications(new Set(readArray));
} catch (e) {
console.error("Failed to parse read notifications:", e);
}
}
}, []);
// Handle shift key for mark all as unread button
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftHeld(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftHeld(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
// Fetch RSS feed function
const fetchNotifications = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(NOTIFICATIONS_ENDPOINT);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseText = await response.text();
// Handle CORS proxy response (JSON wrapper)
let xmlText = responseText;
try {
const jsonResponse = JSON.parse(responseText);
if (jsonResponse.contents) {
xmlText = jsonResponse.contents;
}
} catch {
// If it's not JSON, assume it's direct XML
xmlText = responseText;
}
// Basic validation that we got XML content
if (
!xmlText ||
(!xmlText.includes("<rss") && !xmlText.includes("<feed"))
) {
throw new Error("Invalid RSS feed format");
}
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
// Check for parsing errors
const parserError = xmlDoc.querySelector("parsererror");
if (parserError) {
throw new Error("Failed to parse RSS feed");
}
// Ensure we have a valid document
if (!xmlDoc || !xmlDoc.documentElement) {
throw new Error("Invalid XML document");
}
const items = xmlDoc.querySelectorAll("item");
if (!items || items.length === 0) {
throw new Error("No items found in RSS feed");
}
const parsedNotifications: NotificationItem[] = [];
const autoReadGuids: string[] = [];
// Mark notifications older than 14 days as read
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
items.forEach((item) => {
try {
const guid = item.querySelector("guid")?.textContent || "";
const title = item.querySelector("title")?.textContent || "";
const link = item.querySelector("link")?.textContent || "";
const description =
item.querySelector("description")?.textContent || "";
const pubDate = item.querySelector("pubDate")?.textContent || "";
const category = item.querySelector("category")?.textContent || "";
// Skip items without essential data
if (!guid || !title) {
return;
}
// Parse the publication date
const notificationDate = new Date(pubDate);
// Include all notifications, but collect old ones to mark as read
parsedNotifications.push({
guid,
title,
link,
description,
pubDate,
category,
});
// Collect GUIDs of notifications older than 14 days
if (notificationDate <= fourteenDaysAgo) {
autoReadGuids.push(guid);
}
} catch (itemError) {
// Skip malformed items
console.warn("Skipping malformed RSS item:", itemError);
}
});
setNotifications(parsedNotifications);
// Update read notifications after setting notifications
if (autoReadGuids.length > 0) {
setReadNotifications((prevReadSet) => {
const newReadSet = new Set(prevReadSet);
autoReadGuids.forEach((guid) => newReadSet.add(guid));
// Update localStorage
localStorage.setItem(
"read-notifications",
JSON.stringify(Array.from(newReadSet)),
);
return newReadSet;
});
}
} catch (err) {
console.error("RSS fetch error:", err);
setError(
err instanceof Error ? err.message : "Failed to load notifications",
);
// Set empty notifications to prevent crashes
setNotifications([]);
} finally {
setLoading(false);
}
}, []);
// Initial fetch
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
// Refresh function
const handleRefresh = () => {
fetchNotifications();
};
// Save read notifications to cookie
const markAsRead = (guid: string) => {
const newReadSet = new Set(readNotifications);
newReadSet.add(guid);
setReadNotifications(newReadSet);
// Save to localStorage
localStorage.setItem(
"read-notifications",
JSON.stringify(Array.from(newReadSet)),
);
};
// Mark all as read
const markAllAsRead = () => {
const allGuids = notifications.map((n) => n.guid);
const newReadSet = new Set(allGuids);
setReadNotifications(newReadSet);
localStorage.setItem(
"read-notifications",
JSON.stringify(Array.from(newReadSet)),
);
};
// Mark all as unread
const markAllAsUnread = () => {
setReadNotifications(new Set());
localStorage.setItem("read-notifications", JSON.stringify([]));
};
// Navigate to detail view
const openNotificationDetail = (notification: NotificationItem) => {
setSelectedNotification(notification);
setCurrentView("detail");
markAsRead(notification.guid);
};
// Navigate back to list
const goBackToList = () => {
setCurrentView("list");
setSelectedNotification(null);
};
// Scroll to last read notification
useEffect(() => {
if (
notifications.length > 0 &&
containerRef.current &&
currentView === "list"
) {
const lastReadIndex = notifications.findIndex(
(n) => !readNotifications.has(n.guid),
);
if (lastReadIndex > 0) {
const element = containerRef.current.children[
lastReadIndex
] as HTMLElement;
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}
}, [notifications, readNotifications, currentView]);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateString;
}
};
const getCategoryColor = (category: string) => {
if (!category || category.trim() === "") {
return "";
}
switch (category.toLowerCase()) {
case "announcement":
return "bg-blue-500";
case "feature":
return "bg-green-500";
case "update":
return "bg-yellow-500";
case "bugfix":
return "bg-red-500";
default:
return "";
}
};
const getCategoryLabel = (category: string) => {
switch (category.toLowerCase()) {
case "announcement":
return "Announcement";
case "feature":
return "New Feature";
case "update":
return "Update";
case "bugfix":
return "Bug Fix";
default:
return category;
}
};
const unreadCount = notifications.filter(
(n) => !readNotifications.has(n.guid),
).length;
// Don't render if there's a critical error
if (error && !loading) {
return (
<FancyModal id={id} title="Notifications" size="lg">
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Icon icon={Icons.WARNING} className="text-[2rem] text-red-400" />
<p className="text-red-400 mb-2">Failed to load notifications</p>
<p className="text-sm text-type-secondary">{error}</p>
<button
type="button"
onClick={handleRefresh}
className="mt-4 text-sm text-type-link hover:text-type-linkHover transition-colors"
>
Try again
</button>
</div>
</FancyModal>
);
}
return (
<FancyModal
id={id}
title={
currentView === "list" ? "Notifications" : selectedNotification?.title
}
size="lg"
>
{currentView === "list" ? (
<ListView
notifications={notifications}
readNotifications={readNotifications}
unreadCount={unreadCount}
loading={loading}
error={error}
containerRef={containerRef}
markAllAsRead={markAllAsRead}
markAllAsUnread={markAllAsUnread}
isShiftHeld={isShiftHeld}
onRefresh={handleRefresh}
openNotificationDetail={openNotificationDetail}
getCategoryColor={getCategoryColor}
getCategoryLabel={getCategoryLabel}
formatDate={formatDate}
/>
) : selectedNotification ? (
<DetailView
selectedNotification={selectedNotification}
goBackToList={goBackToList}
getCategoryColor={getCategoryColor}
getCategoryLabel={getCategoryLabel}
formatDate={formatDate}
/>
) : null}
</FancyModal>
);
}
// Hook to manage notifications
export function useNotifications() {
const { showModal, hideModal, isModalVisible } = useOverlayStack();
const modalId = "notifications";
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
// Fetch notifications for badge count
useEffect(() => {
const fetchNotifications = async () => {
try {
const response = await fetch(NOTIFICATIONS_ENDPOINT);
if (!response.ok) return;
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const items = xmlDoc.querySelectorAll("item");
const parsedNotifications: NotificationItem[] = [];
items.forEach((item) => {
const guid = item.querySelector("guid")?.textContent || "";
const title = item.querySelector("title")?.textContent || "";
const link = item.querySelector("link")?.textContent || "";
const description =
item.querySelector("description")?.textContent || "";
const pubDate = item.querySelector("pubDate")?.textContent || "";
const category = item.querySelector("category")?.textContent || "";
parsedNotifications.push({
guid,
title,
link,
description,
pubDate,
category,
});
});
setNotifications(parsedNotifications);
} catch (err) {
// Silently fail for badge count
}
};
fetchNotifications();
}, []);
const openNotifications = () => {
showModal(modalId);
};
const closeNotifications = () => {
hideModal(modalId);
};
const isNotificationsOpen = () => {
return isModalVisible(modalId);
};
// Get unread count for badge
const getUnreadCount = () => {
try {
const savedRead = localStorage.getItem("read-notifications");
if (!savedRead) return notifications.length; // Return total count if no read data
const readArray = JSON.parse(savedRead);
const readSet = new Set(readArray);
// Get the actual count from the notifications state
return notifications.filter((n: NotificationItem) => !readSet.has(n.guid))
.length;
} catch {
return 0;
}
};
return {
openNotifications,
closeNotifications,
isNotificationsOpen,
getUnreadCount,
};
}

View file

@ -11,6 +11,7 @@ import {
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { NotificationModal } from "@/components/overlays/NotificationModal";
import { useOnlineListener } from "@/hooks/usePing";
import { AboutPage } from "@/pages/About";
import { AdminPage } from "@/pages/admin/AdminPage";
@ -117,6 +118,7 @@ function App() {
return (
<Layout>
<LanguageProvider />
<NotificationModal id="notifications" />
{!showDowntime && (
<Routes>
{/* functional routes */}