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 for multiple backends

Pas 006a45a8 64bbc09e

+565 -44
+3
example.env
··· 9 9 10 10 # make sure the domain does NOT have a slash at the end 11 11 VITE_APP_DOMAIN=http://localhost:5173 12 + 13 + # Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com") 14 + VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
+1 -1
public/config.js
··· 12 12 // Whether to disable hash-based routing, leave this as false if you don't know what this is 13 13 VITE_NORMAL_ROUTER: true, 14 14 15 - // The backend URL to communicate with 15 + // The backend URL(s) to communicate with - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com") 16 16 VITE_BACKEND_URL: null, 17 17 18 18 // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
+20 -2
src/assets/locales/en.json
··· 180 180 "title": "Account information" 181 181 } 182 182 }, 183 + "backendSelection": { 184 + "title": "Select Account Server", 185 + "description": "Choose which backend server to connect to", 186 + "customBackend": "Custom Backend", 187 + "customBackendPlaceholder": "https://", 188 + "confirm": "Confirm", 189 + "cancel": "Cancel", 190 + "active": "Active", 191 + "selecting": "Selecting..." 192 + }, 183 193 "trust": { 184 194 "failed": { 185 195 "text": "Did you configure it correctly?", ··· 1116 1126 "connections": { 1117 1127 "server": { 1118 1128 "description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>", 1119 - "label": "Custom server", 1129 + "label": "Backend Server", 1120 1130 "urlLabel": "Custom server URL", 1131 + "selectBackend": "Select Backend Server", 1132 + "currentBackend": "Current Backend", 1133 + "changeWarning": "Changing backend server will log you out. Continue?", 1134 + "confirm": "Log out and change server", 1135 + "cancel": "Cancel", 1136 + "changeWarningTitle": "Change Backend Server", 1121 1137 "migration": { 1122 1138 "description": "<0>Migrate my data</0> to a new server.", 1123 1139 "link": "Migrate my data" ··· 1378 1394 "contentMismatch": "Cannot join watch party: The content does not match the host's content.", 1379 1395 "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.", 1380 1396 "validating": "Validating watch party...", 1381 - "linkCopied": "Copied!" 1397 + "linkCopied": "Copied!", 1398 + "backendRequirement": "All users must use the same backend server", 1399 + "activeBackend": "Active Backend: {{backend}}" 1382 1400 } 1383 1401 }
+223
src/components/form/BackendSelector.tsx
··· 1 + import classNames from "classnames"; 2 + import { useEffect, useState } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + 5 + import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta"; 6 + import { Button } from "@/components/buttons/Button"; 7 + import { Icon, Icons } from "@/components/Icon"; 8 + import { Loading } from "@/components/layout/Loading"; 9 + import { TextInputControl } from "@/components/text-inputs/TextInputControl"; 10 + 11 + interface BackendOption { 12 + url: string; 13 + meta: MetaResponse | null; 14 + loading: boolean; 15 + error: boolean; 16 + } 17 + 18 + interface BackendSelectorProps { 19 + selectedUrl: string | null; 20 + onSelect: (url: string | null) => void; 21 + availableUrls: string[]; 22 + showCustom?: boolean; 23 + } 24 + 25 + function BackendOptionItem({ 26 + option, 27 + isSelected, 28 + onClick, 29 + }: { 30 + option: BackendOption; 31 + isSelected: boolean; 32 + onClick: () => void; 33 + }) { 34 + const { t } = useTranslation(); 35 + const hostname = option.url ? new URL(option.url).hostname : undefined; 36 + 37 + return ( 38 + <button 39 + type="button" 40 + onClick={onClick} 41 + className={classNames( 42 + "w-full p-4 rounded-lg border-2 transition-colors text-left tabbable", 43 + isSelected 44 + ? "border-buttons-purple bg-buttons-purple/10" 45 + : "border-transparent bg-authentication-inputBg hover:bg-authentication-inputBg/80", 46 + )} 47 + > 48 + <div className="flex items-center gap-3"> 49 + <div 50 + className={classNames( 51 + "w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0", 52 + isSelected 53 + ? "border-buttons-purple bg-buttons-purple" 54 + : "border-type-secondary", 55 + )} 56 + > 57 + {isSelected ? ( 58 + <Icon icon={Icons.CHECKMARK} className="text-white text-xs" /> 59 + ) : null} 60 + </div> 61 + <div className="flex-1 min-w-0"> 62 + {option.loading ? ( 63 + <div className="flex items-center gap-2"> 64 + <Loading /> 65 + <span className="text-type-secondary text-sm"> 66 + {t("auth.backendSelection.selecting")} 67 + </span> 68 + </div> 69 + ) : option.error ? ( 70 + <div> 71 + <p className="text-white font-medium">{hostname}</p> 72 + <p className="text-type-secondary text-sm">{option.url}</p> 73 + </div> 74 + ) : option.meta ? ( 75 + <div> 76 + <p className="text-white font-medium">{option.meta.name}</p> 77 + <p className="text-type-secondary text-sm">{hostname}</p> 78 + </div> 79 + ) : ( 80 + <div> 81 + <p className="text-white font-medium">{hostname}</p> 82 + <p className="text-type-secondary text-sm">{option.url}</p> 83 + </div> 84 + )} 85 + </div> 86 + {isSelected ? ( 87 + <span className="text-buttons-purple text-sm font-medium"> 88 + {t("auth.backendSelection.active")} 89 + </span> 90 + ) : null} 91 + </div> 92 + </button> 93 + ); 94 + } 95 + 96 + export function BackendSelector({ 97 + selectedUrl, 98 + onSelect, 99 + availableUrls, 100 + showCustom = true, 101 + }: BackendSelectorProps) { 102 + const { t } = useTranslation(); 103 + const [customUrl, setCustomUrl] = useState(""); 104 + const [backendOptions, setBackendOptions] = useState<BackendOption[]>([]); 105 + 106 + // Initialize and fetch meta for backend options 107 + useEffect(() => { 108 + const fetchMetas = async () => { 109 + const options: BackendOption[] = availableUrls.map((url) => ({ 110 + url, 111 + meta: null, 112 + loading: true, 113 + error: false, 114 + })); 115 + setBackendOptions(options); 116 + 117 + const promises = options.map(async (option) => { 118 + try { 119 + const meta = await getBackendMeta(option.url); 120 + return { ...option, meta, loading: false, error: false }; 121 + } catch { 122 + return { ...option, meta: null, loading: false, error: true }; 123 + } 124 + }); 125 + const results = await Promise.all(promises); 126 + setBackendOptions(results); 127 + }; 128 + 129 + if (availableUrls.length > 0) { 130 + fetchMetas(); 131 + } 132 + }, [availableUrls]); 133 + 134 + const handleCustomUrlSelect = () => { 135 + if (customUrl.trim()) { 136 + let url = customUrl.trim(); 137 + if (!url.startsWith("http://") && !url.startsWith("https://")) { 138 + url = `https://${url}`; 139 + } 140 + onSelect(url); 141 + } 142 + }; 143 + 144 + const isCustomUrlSelected = 145 + customUrl && 146 + selectedUrl === customUrl && 147 + !availableUrls.includes(selectedUrl); 148 + 149 + return ( 150 + <div className="space-y-4"> 151 + {backendOptions.length > 0 ? ( 152 + <div className="space-y-3"> 153 + {backendOptions.map((option) => ( 154 + <BackendOptionItem 155 + key={option.url} 156 + option={option} 157 + isSelected={selectedUrl === option.url} 158 + onClick={() => onSelect(option.url)} 159 + /> 160 + ))} 161 + </div> 162 + ) : null} 163 + 164 + {showCustom && ( 165 + <div 166 + className={classNames( 167 + "w-full p-4 rounded-lg border-2 transition-colors", 168 + isCustomUrlSelected 169 + ? "border-buttons-purple bg-buttons-purple/10" 170 + : "border-transparent bg-authentication-inputBg", 171 + )} 172 + > 173 + <div className="space-y-3"> 174 + <div className="flex items-center gap-3"> 175 + <div 176 + className={classNames( 177 + "w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0", 178 + isCustomUrlSelected 179 + ? "border-buttons-purple bg-buttons-purple" 180 + : "border-type-secondary", 181 + )} 182 + > 183 + {isCustomUrlSelected ? ( 184 + <Icon icon={Icons.CHECKMARK} className="text-white text-xs" /> 185 + ) : null} 186 + </div> 187 + <div className="flex-1"> 188 + <p className="text-white font-medium"> 189 + {t("auth.backendSelection.customBackend")} 190 + </p> 191 + </div> 192 + {isCustomUrlSelected ? ( 193 + <span className="text-buttons-purple text-sm font-medium"> 194 + {t("auth.backendSelection.active")} 195 + </span> 196 + ) : null} 197 + </div> 198 + <div className="space-y-3"> 199 + <div className="grid grid-cols-[1fr,auto] items-center gap-2"> 200 + <TextInputControl 201 + value={customUrl} 202 + onChange={setCustomUrl} 203 + placeholder={ 204 + t("auth.backendSelection.customBackendPlaceholder") ?? 205 + undefined 206 + } 207 + className="w-full flex-1 bg-authentication-inputBg border-2 border-type-secondary/40 px-4 py-3 text-search-text focus:outline-none rounded-lg placeholder:text-gray-700" 208 + /> 209 + <Button 210 + theme="purple" 211 + onClick={handleCustomUrlSelect} 212 + disabled={!customUrl.trim()} 213 + > 214 + {t("auth.backendSelection.confirm")} 215 + </Button> 216 + </div> 217 + </div> 218 + </div> 219 + </div> 220 + )} 221 + </div> 222 + ); 223 + }
+8
src/components/player/atoms/WatchPartyStatus.tsx
··· 3 3 4 4 import { Button } from "@/components/buttons/Button"; 5 5 import { Icon, Icons } from "@/components/Icon"; 6 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 6 7 import { useWatchPartySync } from "@/hooks/useWatchPartySync"; 7 8 import { useAuthStore } from "@/stores/auth"; 8 9 import { getProgressPercentage } from "@/stores/progress"; ··· 15 16 const [showNotification, setShowNotification] = useState(false); 16 17 const [lastUserCount, setLastUserCount] = useState(1); 17 18 const account = useAuthStore((s) => s.account); 19 + const backendUrl = useBackendUrl(); 20 + const backendHostname = backendUrl ? new URL(backendUrl).hostname : null; 18 21 19 22 const { 20 23 roomUsers, ··· 70 73 {roomCode} 71 74 </span> 72 75 </div> 76 + {backendHostname && ( 77 + <div className="w-full text-xs text-type-secondary text-center"> 78 + {t("watchParty.activeBackend", { backend: backendHostname })} 79 + </div> 80 + )} 73 81 74 82 <div className="w-full text-type-secondary flex justify-between items-center space-x-2"> 75 83 <div className="cursor-pointer" onClick={handleToggleExpanded}>
+11 -1
src/components/player/atoms/settings/WatchPartyView.tsx
··· 220 220 ) : ( 221 221 <> 222 222 <div className="flex flex-col gap-2"> 223 - <div className="text-center"> 223 + <div className="text-center space-y-2"> 224 + <div className="text-xs text-type-logo font-semibold flex flex-col gap-1 bg-type-danger/10 px-2 py-1 rounded mb-2"> 225 + <span className="text-xs"> 226 + {t("watchParty.backendRequirement")} 227 + </span> 228 + <span className="text-xs"> 229 + {t("watchParty.activeBackend", { 230 + backend: backendUrl || "Unknown", 231 + })} 232 + </span> 233 + </div> 224 234 <Trans 225 235 i18nKey={ 226 236 isHost ? "watchParty.isHost" : "watchParty.isGuest"
+6 -1
src/hooks/auth/useBackendUrl.ts
··· 3 3 4 4 export function useBackendUrl(): string | null { 5 5 const backendUrl = useAuthStore((s) => s.backendUrl); 6 - return backendUrl ?? conf().BACKEND_URL; 6 + const config = conf(); 7 + return ( 8 + backendUrl ?? 9 + config.BACKEND_URL ?? 10 + (config.BACKEND_URLS.length > 0 ? config.BACKEND_URLS[0] : null) 11 + ); 7 12 }
+77 -5
src/pages/Login.tsx
··· 1 + import { useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 1 3 import { useNavigate } from "react-router-dom"; 2 4 5 + import { Button } from "@/components/buttons/Button"; 6 + import { BackendSelector } from "@/components/form/BackendSelector"; 7 + import { 8 + LargeCard, 9 + LargeCardButtons, 10 + LargeCardText, 11 + } from "@/components/layout/LargeCard"; 3 12 import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 4 13 import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart"; 5 14 import { PageTitle } from "@/pages/parts/util/PageTitle"; 15 + import { conf } from "@/setup/config"; 16 + import { useAuthStore } from "@/stores/auth"; 6 17 7 18 export function LoginPage() { 8 19 const navigate = useNavigate(); 20 + const { t } = useTranslation(); 21 + const [showBackendSelection, setShowBackendSelection] = useState(true); 22 + const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>( 23 + null, 24 + ); 25 + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); 26 + const config = conf(); 27 + const availableBackends = 28 + config.BACKEND_URLS.length > 0 29 + ? config.BACKEND_URLS 30 + : config.BACKEND_URL 31 + ? [config.BACKEND_URL] 32 + : []; 33 + 34 + // If there's only one backend and user hasn't selected a custom one, auto-select it 35 + const currentBackendUrl = useAuthStore((s) => s.backendUrl); 36 + const defaultBackend = 37 + currentBackendUrl ?? 38 + (availableBackends.length === 1 ? availableBackends[0] : null); 39 + 40 + const handleBackendSelect = (url: string | null) => { 41 + setSelectedBackendUrl(url); 42 + if (url) { 43 + setBackendUrl(url); 44 + } 45 + }; 46 + 47 + const handleContinue = () => { 48 + if (selectedBackendUrl || defaultBackend) { 49 + if (selectedBackendUrl) { 50 + setBackendUrl(selectedBackendUrl); 51 + } else if (defaultBackend) { 52 + setBackendUrl(defaultBackend); 53 + } 54 + setShowBackendSelection(false); 55 + } 56 + }; 9 57 10 58 return ( 11 59 <SubPageLayout> 12 60 <PageTitle subpage k="global.pages.login" /> 13 - <LoginFormPart 14 - onLogin={() => { 15 - navigate("/"); 16 - }} 17 - /> 61 + {showBackendSelection && 62 + (availableBackends.length > 1 || !defaultBackend) ? ( 63 + <LargeCard> 64 + <LargeCardText title={t("auth.backendSelection.title")}> 65 + {t("auth.backendSelection.description")} 66 + </LargeCardText> 67 + <BackendSelector 68 + selectedUrl={selectedBackendUrl ?? defaultBackend} 69 + onSelect={handleBackendSelect} 70 + availableUrls={availableBackends} 71 + showCustom 72 + /> 73 + <LargeCardButtons> 74 + <Button 75 + theme="purple" 76 + onClick={handleContinue} 77 + disabled={!selectedBackendUrl && !defaultBackend} 78 + > 79 + {t("auth.register.information.next")} 80 + </Button> 81 + </LargeCardButtons> 82 + </LargeCard> 83 + ) : ( 84 + <LoginFormPart 85 + onLogin={() => { 86 + navigate("/"); 87 + }} 88 + /> 89 + )} 18 90 </SubPageLayout> 19 91 ); 20 92 }
+57 -1
src/pages/Register.tsx
··· 1 1 import { useState } from "react"; 2 2 import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; 3 + import { useTranslation } from "react-i18next"; 3 4 import { useNavigate } from "react-router-dom"; 4 5 5 6 import { MetaResponse } from "@/backend/accounts/meta"; 7 + import { Button } from "@/components/buttons/Button"; 8 + import { BackendSelector } from "@/components/form/BackendSelector"; 9 + import { 10 + LargeCard, 11 + LargeCardButtons, 12 + LargeCardText, 13 + } from "@/components/layout/LargeCard"; 6 14 import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 7 15 import { 8 16 AccountCreatePart, ··· 12 20 import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart"; 13 21 import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart"; 14 22 import { PageTitle } from "@/pages/parts/util/PageTitle"; 23 + import { conf } from "@/setup/config"; 24 + import { useAuthStore } from "@/stores/auth"; 15 25 16 26 function CaptchaProvider(props: { 17 27 siteKey: string | null; ··· 27 37 28 38 export function RegisterPage() { 29 39 const navigate = useNavigate(); 30 - const [step, setStep] = useState(0); 40 + const { t } = useTranslation(); 41 + const [step, setStep] = useState(-1); 31 42 const [mnemonic, setMnemonic] = useState<null | string>(null); 32 43 const [account, setAccount] = useState<null | AccountProfile>(null); 33 44 const [siteKey, setSiteKey] = useState<string | null>(null); 45 + const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>( 46 + null, 47 + ); 48 + const setBackendUrl = useAuthStore((s) => s.setBackendUrl); 49 + const config = conf(); 50 + const availableBackends = 51 + config.BACKEND_URLS.length > 0 52 + ? config.BACKEND_URLS 53 + : config.BACKEND_URL 54 + ? [config.BACKEND_URL] 55 + : []; 56 + 57 + const handleBackendSelect = (url: string | null) => { 58 + setSelectedBackendUrl(url); 59 + if (url) { 60 + setBackendUrl(url); 61 + } 62 + }; 34 63 35 64 return ( 36 65 <CaptchaProvider siteKey={siteKey}> 37 66 <SubPageLayout> 38 67 <PageTitle subpage k="global.pages.register" /> 68 + {step === -1 ? ( 69 + <LargeCard> 70 + <LargeCardText title={t("auth.backendSelection.title")}> 71 + {t("auth.backendSelection.description")} 72 + </LargeCardText> 73 + <BackendSelector 74 + selectedUrl={selectedBackendUrl} 75 + onSelect={handleBackendSelect} 76 + availableUrls={availableBackends} 77 + showCustom 78 + /> 79 + <LargeCardButtons> 80 + <Button 81 + theme="purple" 82 + onClick={() => { 83 + if (selectedBackendUrl) { 84 + setStep(0); 85 + } 86 + }} 87 + disabled={!selectedBackendUrl} 88 + > 89 + {t("auth.register.information.next")} 90 + </Button> 91 + </LargeCardButtons> 92 + </LargeCard> 93 + ) : null} 39 94 {step === 0 ? ( 40 95 <TrustBackendPart 96 + backendUrl={selectedBackendUrl} 41 97 onNext={(meta: MetaResponse) => { 42 98 setSiteKey( 43 99 meta.hasCaptcha && meta.captchaClientKey
+55 -8
src/pages/Settings.tsx
··· 16 16 import { SearchBarInput } from "@/components/form/SearchBar"; 17 17 import { ThinContainer } from "@/components/layout/ThinContainer"; 18 18 import { WideContainer } from "@/components/layout/WideContainer"; 19 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 19 20 import { UserIcons } from "@/components/UserIcon"; 20 21 import { Divider } from "@/components/utils/Divider"; 21 - import { Heading1 } from "@/components/utils/Text"; 22 + import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; 22 23 import { Transition } from "@/components/utils/Transition"; 23 24 import { useAuth } from "@/hooks/auth/useAuth"; 24 25 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; ··· 168 169 const [searchQuery, setSearchQuery] = useState(""); 169 170 const [selectedCategory, setSelectedCategory] = useState<string | null>(null); 170 171 const prevCategoryRef = useRef<string | null>(null); 172 + const backendChangeModal = useModal("settings-backend-change-confirmation"); 173 + const [pendingBackendChange, setPendingBackendChange] = useState< 174 + string | null 175 + >(null); 171 176 172 177 useEffect(() => { 173 178 const hash = window.location.hash; ··· 730 735 updateProfile(state.profile.state); 731 736 } 732 737 733 - // when backend url gets changed, log the user out first 738 + // when backend url gets changed, show confirmation and log the user out (only if logged in) 734 739 if (state.backendUrl.changed) { 735 - await logout(); 736 - 737 740 let url = state.backendUrl.state; 738 741 if (url && !url.startsWith("http://") && !url.startsWith("https://")) { 739 742 url = `https://${url}`; 740 743 } 741 - 744 + if (account) { 745 + // User is logged in - show confirmation 746 + setPendingBackendChange(url); 747 + backendChangeModal.show(); 748 + return; 749 + } 750 + // User is not logged in - just update without confirmation 742 751 setBackendUrl(url); 743 752 } 744 753 }, [ 745 754 account, 746 755 backendUrl, 756 + backendChangeModal, 757 + setPendingBackendChange, 758 + state, 759 + setBackendUrl, 747 760 setEnableThumbnails, 748 761 setFebboxKey, 749 762 setdebridToken, 750 763 setdebridService, 751 - state, 752 764 setEnableAutoplay, 753 765 setEnableSkipCredits, 754 766 setEnableDiscover, ··· 766 778 updateDeviceName, 767 779 updateProfile, 768 780 updateNickname, 769 - logout, 770 - setBackendUrl, 771 781 setProxyTmdb, 772 782 setEnableCarouselView, 773 783 setEnableMinimalCards, ··· 948 958 </Button> 949 959 </div> 950 960 </Transition> 961 + {account && ( 962 + <Modal id={backendChangeModal.id}> 963 + <ModalCard> 964 + <Heading2 className="!mt-0 !mb-4"> 965 + {t("settings.connections.server.changeWarningTitle")} 966 + </Heading2> 967 + <Paragraph className="!mt-1 !mb-6"> 968 + {t("settings.connections.server.changeWarning")} 969 + </Paragraph> 970 + <div className="flex justify-end gap-3"> 971 + <Button 972 + theme="secondary" 973 + onClick={() => { 974 + backendChangeModal.hide(); 975 + setPendingBackendChange(null); 976 + state.backendUrl.set(backendUrlSetting); 977 + }} 978 + > 979 + {t("actions.cancel")} 980 + </Button> 981 + <Button 982 + theme="purple" 983 + onClick={async () => { 984 + backendChangeModal.hide(); 985 + if (pendingBackendChange !== null) { 986 + await logout(); 987 + setBackendUrl(pendingBackendChange); 988 + setPendingBackendChange(null); 989 + } 990 + }} 991 + > 992 + {t("actions.confirm")} 993 + </Button> 994 + </div> 995 + </ModalCard> 996 + </Modal> 997 + )} 951 998 </SubPageLayout> 952 999 ); 953 1000 }
+3 -1
src/pages/parts/auth/TrustBackendPart.tsx
··· 16 16 import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 17 17 18 18 interface TrustBackendPartProps { 19 + backendUrl?: string | null; 19 20 onNext?: (meta: MetaResponse) => void; 20 21 } 21 22 22 23 export function TrustBackendPart(props: TrustBackendPartProps) { 23 24 const navigate = useNavigate(); 24 - const backendUrl = useBackendUrl(); 25 + const defaultBackendUrl = useBackendUrl(); 26 + const backendUrl = props.backendUrl ?? defaultBackendUrl; 25 27 const hostname = useMemo( 26 28 () => (backendUrl ? new URL(backendUrl).hostname : undefined), 27 29 [backendUrl],
+82 -23
src/pages/parts/settings/ConnectionsPart.tsx
··· 9 9 10 10 import { Button } from "@/components/buttons/Button"; 11 11 import { Toggle } from "@/components/buttons/Toggle"; 12 + import { BackendSelector } from "@/components/form/BackendSelector"; 12 13 import { Dropdown } from "@/components/form/Dropdown"; 13 14 import { Icon, Icons } from "@/components/Icon"; 14 15 import { SettingsCard } from "@/components/layout/SettingsCard"; ··· 184 185 function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) { 185 186 const { t } = useTranslation(); 186 187 const user = useAuthStore(); 188 + const config = conf(); 189 + const availableBackends = 190 + config.BACKEND_URLS.length > 0 191 + ? config.BACKEND_URLS 192 + : config.BACKEND_URL 193 + ? [config.BACKEND_URL] 194 + : []; 195 + const currentBackendUrl = 196 + backendUrl ?? (availableBackends.length > 0 ? availableBackends[0] : null); 197 + const [pendingBackendUrl, setPendingBackendUrl] = useState<string | null>( 198 + currentBackendUrl, 199 + ); 200 + const confirmationModal = useModal("backend-change-confirmation"); 201 + 202 + const handleBackendSelect = (url: string | null) => { 203 + if (!user.account) { 204 + // No account - just update without confirmation 205 + setBackendUrl(url); 206 + setPendingBackendUrl(url); 207 + } else if (url !== currentBackendUrl) { 208 + // User is logged in and changing backend - show confirmation 209 + setPendingBackendUrl(url); 210 + confirmationModal.show(); 211 + } else { 212 + // Same backend - just update 213 + setBackendUrl(url); 214 + setPendingBackendUrl(url); 215 + } 216 + }; 217 + 218 + const handleConfirmChange = () => { 219 + setBackendUrl(pendingBackendUrl); 220 + confirmationModal.hide(); 221 + }; 222 + 187 223 return ( 188 - <SettingsCard> 189 - <div className="flex justify-between items-center gap-4"> 224 + <> 225 + <SettingsCard> 190 226 <div className="my-3"> 191 227 <p className="text-white font-bold mb-3"> 192 228 {t("settings.connections.server.label")} ··· 211 247 </div> 212 248 )} 213 249 </div> 214 - <div> 215 - <Toggle 216 - onClick={() => setBackendUrl((s) => (s === null ? "" : null))} 217 - enabled={backendUrl !== null} 218 - /> 219 - </div> 220 - </div> 221 - {backendUrl !== null ? ( 222 - <> 223 - <Divider marginClass="my-6 px-8 box-content -mx-8" /> 224 - <p className="text-white font-bold mb-3"> 225 - {t("settings.connections.server.urlLabel")} 226 - </p> 227 - <AuthInputBox 228 - onChange={setBackendUrl} 229 - value={backendUrl ?? ""} 230 - placeholder="https://" 231 - /> 232 - </> 233 - ) : null} 234 - </SettingsCard> 250 + {(availableBackends.length > 0 || currentBackendUrl) && ( 251 + <> 252 + <Divider marginClass="my-6 px-8 box-content -mx-8" /> 253 + <p className="text-white font-bold mb-3"> 254 + {t("settings.connections.server.selectBackend")} 255 + </p> 256 + {availableBackends.length > 0 ? ( 257 + <BackendSelector 258 + selectedUrl={currentBackendUrl} 259 + onSelect={handleBackendSelect} 260 + availableUrls={availableBackends} 261 + showCustom 262 + /> 263 + ) : ( 264 + <AuthInputBox 265 + onChange={setBackendUrl} 266 + value={backendUrl ?? ""} 267 + placeholder="https://" 268 + /> 269 + )} 270 + </> 271 + )} 272 + </SettingsCard> 273 + {user.account && ( 274 + <Modal id={confirmationModal.id}> 275 + <ModalCard> 276 + <Heading2 className="!mt-0 !mb-4"> 277 + {t("settings.connections.server.changeWarningTitle")} 278 + </Heading2> 279 + <Paragraph className="!mt-1 !mb-6"> 280 + {t("settings.connections.server.changeWarning")} 281 + </Paragraph> 282 + <div className="flex justify-end gap-3"> 283 + <Button theme="secondary" onClick={confirmationModal.hide}> 284 + {t("settings.connections.server.cancel")} 285 + </Button> 286 + <Button theme="purple" onClick={handleConfirmChange}> 287 + {t("settings.connections.server.confirm")} 288 + </Button> 289 + </div> 290 + </ModalCard> 291 + </Modal> 292 + )} 293 + </> 235 294 ); 236 295 } 237 296
+19 -1
src/setup/config.ts
··· 49 49 PROXY_URLS: string[]; 50 50 M3U8_PROXY_URLS: string[]; 51 51 BACKEND_URL: string | null; 52 + BACKEND_URLS: string[]; 52 53 DISALLOWED_IDS: string[]; 53 54 CDN_REPLACEMENTS: Array<string[]>; 54 55 HAS_ONBOARDING: boolean; ··· 137 138 "https://docs.pstream.mov/extension", 138 139 ), 139 140 ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"), 140 - BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL), 141 + BACKEND_URLS: getKey("BACKEND_URL", BACKEND_URL) 142 + ? getKey("BACKEND_URL", BACKEND_URL) 143 + .split(",") 144 + .map((v) => v.trim()) 145 + .filter((v) => v.length > 0) 146 + : [], 147 + BACKEND_URL: (() => { 148 + const backendUrlValue = getKey("BACKEND_URL", BACKEND_URL); 149 + if (!backendUrlValue) return backendUrlValue; 150 + if (backendUrlValue.includes(",")) { 151 + const urls = backendUrlValue 152 + .split(",") 153 + .map((v) => v.trim()) 154 + .filter((v) => v.length > 0); 155 + return urls.length > 0 ? urls[0] : backendUrlValue; 156 + } 157 + return backendUrlValue; 158 + })(), 141 159 TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), 142 160 PROXY_URLS: getKey("CORS_PROXY_URL", "") 143 161 .split(",")