pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add support bar and donation modal to homepage

Introduces a support bar component on the homepage to display project funding progress and encourage donations. Adds a modal with more information about supporting the project. Updates configuration to allow toggling the support bar and setting funding values. Updates links to the new donation page and adds related translations.

Pas f2b39b04 dbf2c02a

+199 -3
+1 -1
public/notifications.xml
··· 257 257 258 258 If you are interested in donating, please check the link below! 259 259 </description> 260 - <link>https://rentry.co/h5mypdfs</link> 260 + <link>https://rentry.co/nnqtas3e</link> 261 261 <pubDate>Sat, 06 Sep 2025 14:42:00 MST</pubDate> 262 262 <category>announcement</category> 263 263 </item>
+12
src/assets/locales/en.json
··· 392 392 "It's the Great Pumpkin, Charlie Brown!" 393 393 ] 394 394 } 395 + }, 396 + "support": { 397 + "title": "P-Stream needs your help!", 398 + "description": "P-Stream is run at a loss, and we need help to keep it ad free! If you enjoy using P-Stream, please consider donating to help us cover our costs.", 399 + "moreInfo": "More info", 400 + "explanation": "If you aren't using the extension or don't have FED API set up, it may be harder to find content! We want to fix this, but it's a lot harder to provide content without expensive servers. So please, if you enjoy using P-Stream, please consider donating to help us cover our growing costs.", 401 + "explanation2": "If you want more info, please join our ", 402 + "discord": "Discord", 403 + "thankYou": "Thank you for your support!", 404 + "donate": "Donate", 405 + "label": "Project Funding: ${{current}} / ${{goal}}", 406 + "complete": "complete" 395 407 } 396 408 }, 397 409 "media": {
+1 -1
src/components/LinksDropdown.tsx
··· 315 315 /> 316 316 <CircleDropdownLink href="/support" icon={Icons.SUPPORT} /> 317 317 <CircleDropdownLink 318 - href="https://rentry.co/h5mypdfs" 318 + href="https://rentry.co/nnqtas3e" 319 319 icon={Icons.TIP_JAR} 320 320 /> 321 321 </div>
+1 -1
src/components/layout/Footer.tsx
··· 83 83 <FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}> 84 84 {t("footer.links.discord")} 85 85 </FooterLink> 86 - <FooterLink href="https://rentry.co/h5mypdfs" icon={Icons.TIP_JAR}> 86 + <FooterLink href="https://rentry.co/nnqtas3e" icon={Icons.TIP_JAR}> 87 87 {t("footer.links.funding")} 88 88 </FooterLink> 89 89 <div className="inline md:hidden">
+40
src/components/overlays/SupportInfoModal.tsx
··· 1 + import { useTranslation } from "react-i18next"; 2 + 3 + import { FancyModal } from "./Modal"; 4 + import { Button } from "../buttons/Button"; 5 + import { MwLink } from "../text/Link"; 6 + 7 + export function SupportInfoModal({ id }: { id: string }) { 8 + const { t } = useTranslation(); 9 + 10 + return ( 11 + <FancyModal id={id} title={t("home.support.title")} size="md"> 12 + <div className="space-y-4"> 13 + <p className="text-type-secondary">{t("home.support.explanation")}</p> 14 + <p className="text-type-secondary"> 15 + {t("home.support.explanation2")}{" "} 16 + <MwLink url="https://discord.gg/7z6znYgrTG"> 17 + {t("home.support.discord")} 18 + </MwLink> 19 + </p> 20 + 21 + <div className="space-y-3"> 22 + <span className="text-center flex justify-center whitespace-nowrap items-center"> 23 + <Button 24 + theme="purple" 25 + onClick={() => 26 + window.open("https://rentry.co/nnqtas3e", "_blank") 27 + } 28 + > 29 + {t("home.support.donate")} 30 + </Button> 31 + </span> 32 + </div> 33 + 34 + <div className="text-xs text-type-dimmed text-center"> 35 + {t("home.support.thankYou")} 36 + </div> 37 + </div> 38 + </FancyModal> 39 + ); 40 + }
+3
src/pages/HomePage.tsx
··· 25 25 26 26 import { Button } from "./About"; 27 27 import { AdsPart } from "./parts/home/AdsPart"; 28 + import { SupportBar } from "./parts/home/SupportBar"; 28 29 29 30 function useSearch(search: string) { 30 31 const [searching, setSearching] = useState<boolean>(false); ··· 170 171 showTitle 171 172 /> 172 173 )} 174 + 175 + {conf().SHOW_SUPPORT_BAR ? <SupportBar /> : null} 173 176 174 177 {conf().SHOW_AD ? <AdsPart /> : null} 175 178 </div>
+131
src/pages/parts/home/SupportBar.tsx
··· 1 + import { useCallback, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { Icon, Icons } from "@/components/Icon"; 5 + import { SettingsCard } from "@/components/layout/SettingsCard"; 6 + import { MwLink } from "@/components/text/Link"; 7 + import { Heading3 } from "@/components/utils/Text"; 8 + import { conf } from "@/setup/config"; 9 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 10 + 11 + function getCookie(name: string): string | null { 12 + const cookies = document.cookie.split(";"); 13 + for (let i = 0; i < cookies.length; i += 1) { 14 + const cookie = cookies[i].trim(); 15 + if (cookie.startsWith(`${name}=`)) { 16 + return cookie.substring(name.length + 1); 17 + } 18 + } 19 + return null; 20 + } 21 + 22 + function setCookie(name: string, value: string, expiryDays: number): void { 23 + const date = new Date(); 24 + date.setTime(date.getTime() + expiryDays * 24 * 60 * 60 * 1000); 25 + const expires = `expires=${date.toUTCString()}`; 26 + document.cookie = `${name}=${value};${expires};path=/`; 27 + } 28 + 29 + export function SupportBar() { 30 + const { t } = useTranslation(); 31 + const { showModal } = useOverlayStack(); 32 + const [isDescriptionDismissed, setIsDescriptionDismissed] = useState(() => { 33 + return getCookie("supportDescriptionDismissed") === "true"; 34 + }); 35 + 36 + const toggleDescription = useCallback(() => { 37 + const newState = !isDescriptionDismissed; 38 + setIsDescriptionDismissed(newState); 39 + setCookie("supportDescriptionDismissed", newState ? "true" : "false", 14); // Expires after 14 days 40 + }, [isDescriptionDismissed]); 41 + 42 + const openSupportModal = useCallback(() => { 43 + showModal("support-info"); 44 + }, [showModal]); 45 + 46 + const supportValue = conf().SUPPORT_BAR_VALUE; 47 + if (!supportValue) return null; 48 + 49 + // Parse fraction like "100/300" 50 + const [currentStr, goalStr] = supportValue.split("/"); 51 + const current = parseFloat(currentStr) || 0; 52 + const goal = parseFloat(goalStr) || 1; 53 + 54 + const percentage = Math.min((current / goal) * 100, 100); 55 + 56 + return ( 57 + <div className="w-full px-4 py-2"> 58 + <div className="flex flex-col items-center space-y-2"> 59 + <SettingsCard className="max-w-md relative group"> 60 + <button 61 + onClick={toggleDescription} 62 + type="button" 63 + className="absolute z-20 -top-2 -right-2 w-6 h-6 bg-mediaCard-hoverBackground rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300" 64 + aria-label={ 65 + isDescriptionDismissed ? "Show description" : "Hide description" 66 + } 67 + > 68 + <Icon 69 + className="text-s font-semibold text-type-secondary" 70 + icon={ 71 + isDescriptionDismissed ? Icons.CHEVRON_UP : Icons.CHEVRON_DOWN 72 + } 73 + /> 74 + </button> 75 + <div 76 + className={`overflow-hidden transition-all duration-300 ${ 77 + isDescriptionDismissed 78 + ? "max-h-0 opacity-0 pb-0" 79 + : "max-h-32 opacity-100 pb-4" 80 + }`} 81 + > 82 + <Heading3 className="transition-opacity duration-300"> 83 + {t("home.support.title")} 84 + </Heading3> 85 + <p className="text-type-secondary max-w-md pb-4 transition-opacity duration-300"> 86 + {t("home.support.description")} 87 + </p> 88 + </div> 89 + <div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pb-4"> 90 + <span className="text-left"> 91 + {t("home.support.label", { 92 + current: current.toLocaleString(), 93 + goal: goal.toLocaleString(), 94 + })} 95 + </span> 96 + <span className="ml-auto text-right flex-shrink-0 whitespace-nowrap"> 97 + {percentage.toFixed(1)}% {t("home.support.complete")} 98 + </span> 99 + </div> 100 + <div className="w-full max-w-md"> 101 + <div className="relative w-full h-2 bg-progress-background bg-opacity-25 rounded-full"> 102 + {/* Progress bar */} 103 + <div 104 + className="absolute top-0 left-0 h-full rounded-full bg-progress-filled transition-all duration-300" 105 + style={{ 106 + width: `${percentage}%`, 107 + }} 108 + /> 109 + </div> 110 + </div> 111 + <div className="flex flex-grow items-center text-sm text-type-dimmed w-full max-w-md pt-4"> 112 + <span className="text-left"> 113 + <button 114 + type="button" 115 + onClick={openSupportModal} 116 + className="group mt-1 cursor-pointer font-bold text-type-link hover:text-type-linkHover active:scale-95" 117 + > 118 + {t("home.support.moreInfo")} 119 + </button> 120 + </span> 121 + <span className="ml-auto text-right flex-shrink-0 whitespace-nowrap"> 122 + <MwLink url="https://rentry.co/nnqtas3e"> 123 + {t("home.support.donate")} 124 + </MwLink> 125 + </span> 126 + </div> 127 + </SettingsCard> 128 + </div> 129 + </div> 130 + ); 131 + }
+2
src/setup/App.tsx
··· 14 14 import { DetailsModal } from "@/components/overlays/detailsModal"; 15 15 import { KeyboardCommandsModal } from "@/components/overlays/KeyboardCommandsModal"; 16 16 import { NotificationModal } from "@/components/overlays/notificationsModal"; 17 + import { SupportInfoModal } from "@/components/overlays/SupportInfoModal"; 17 18 import { useGlobalKeyboardEvents } from "@/hooks/useGlobalKeyboardEvents"; 18 19 import { useOnlineListener } from "@/hooks/usePing"; 19 20 import { AboutPage } from "@/pages/About"; ··· 126 127 <LanguageProvider /> 127 128 <NotificationModal id="notifications" /> 128 129 <KeyboardCommandsModal id="keyboard-commands" /> 130 + <SupportInfoModal id="support-info" /> 129 131 <DetailsModal id="details" /> 130 132 <DetailsModal id="discover-details" /> 131 133 <DetailsModal id="player-details" />
+8
src/setup/config.ts
··· 34 34 BANNER_ID: string; 35 35 USE_TRAKT: boolean; 36 36 HIDE_PROXY_ONBOARDING: boolean; 37 + SHOW_SUPPORT_BAR: boolean; 38 + SUPPORT_BAR_VALUE: string; 37 39 } 38 40 39 41 export interface RuntimeConfig { ··· 64 66 BANNER_ID: string | null; 65 67 USE_TRAKT: boolean; 66 68 HIDE_PROXY_ONBOARDING: boolean; 69 + SHOW_SUPPORT_BAR: boolean; 70 + SUPPORT_BAR_VALUE: string; 67 71 } 68 72 69 73 const env: Record<keyof Config, undefined | string> = { ··· 97 101 BANNER_ID: import.meta.env.VITE_BANNER_ID, 98 102 USE_TRAKT: import.meta.env.VITE_USE_TRAKT, 99 103 HIDE_PROXY_ONBOARDING: import.meta.env.VITE_HIDE_PROXY_ONBOARDING, 104 + SHOW_SUPPORT_BAR: import.meta.env.VITE_SHOW_SUPPORT_BAR, 105 + SUPPORT_BAR_VALUE: import.meta.env.VITE_SUPPORT_BAR_VALUE, 100 106 }; 101 107 102 108 function coerceUndefined(value: string | null | undefined): string | undefined { ··· 173 179 BANNER_ID: getKey("BANNER_ID"), 174 180 USE_TRAKT: getKey("USE_TRAKT", "false") === "true", 175 181 HIDE_PROXY_ONBOARDING: getKey("HIDE_PROXY_ONBOARDING", "false") === "true", 182 + SHOW_SUPPORT_BAR: getKey("SHOW_SUPPORT_BAR", "false") === "true", 183 + SUPPORT_BAR_VALUE: getKey("SUPPORT_BAR_VALUE") ?? "", 176 184 }; 177 185 }