mirror of
https://github.com/p-stream/p-stream.git
synced 2026-04-20 18:32:09 +00:00
Add notifications feed
This commit is contained in:
parent
78af5d76b8
commit
6d362294c4
5 changed files with 878 additions and 0 deletions
124
public/notifications.xml
Normal file
124
public/notifications.xml
Normal 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 & 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>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
734
src/components/overlays/NotificationModal.tsx
Normal file
734
src/components/overlays/NotificationModal.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue