mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 12:00:45 +00:00
add support for multiple backends
This commit is contained in:
parent
64bbc09e99
commit
006a45a84a
13 changed files with 565 additions and 44 deletions
|
|
@ -9,3 +9,6 @@ VITE_M3U8_PROXY_URL=...
|
|||
|
||||
# make sure the domain does NOT have a slash at the end
|
||||
VITE_APP_DOMAIN=http://localhost:5173
|
||||
|
||||
# Backend URL(s) - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com,https://server3.com")
|
||||
VITE_BACKEND_URL=https://server1.com,https://server2.com,https://server3.com
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ window.__CONFIG__ = {
|
|||
// Whether to disable hash-based routing, leave this as false if you don't know what this is
|
||||
VITE_NORMAL_ROUTER: true,
|
||||
|
||||
// The backend URL to communicate with
|
||||
// The backend URL(s) to communicate with - can be a single URL or comma-separated list (e.g., "https://server1.com,https://server2.com")
|
||||
VITE_BACKEND_URL: null,
|
||||
|
||||
// A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-<id>" and "movie-<id>"
|
||||
|
|
|
|||
|
|
@ -180,6 +180,16 @@
|
|||
"title": "Account information"
|
||||
}
|
||||
},
|
||||
"backendSelection": {
|
||||
"title": "Select Account Server",
|
||||
"description": "Choose which backend server to connect to",
|
||||
"customBackend": "Custom Backend",
|
||||
"customBackendPlaceholder": "https://",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"active": "Active",
|
||||
"selecting": "Selecting..."
|
||||
},
|
||||
"trust": {
|
||||
"failed": {
|
||||
"text": "Did you configure it correctly?",
|
||||
|
|
@ -1116,8 +1126,14 @@
|
|||
"connections": {
|
||||
"server": {
|
||||
"description": "If you would like to connect to a custom backend to store your data, enable this and provide the URL. <0>Instructions.</0>",
|
||||
"label": "Custom server",
|
||||
"label": "Backend Server",
|
||||
"urlLabel": "Custom server URL",
|
||||
"selectBackend": "Select Backend Server",
|
||||
"currentBackend": "Current Backend",
|
||||
"changeWarning": "Changing backend server will log you out. Continue?",
|
||||
"confirm": "Log out and change server",
|
||||
"cancel": "Cancel",
|
||||
"changeWarningTitle": "Change Backend Server",
|
||||
"migration": {
|
||||
"description": "<0>Migrate my data</0> to a new server.",
|
||||
"link": "Migrate my data"
|
||||
|
|
@ -1378,6 +1394,8 @@
|
|||
"contentMismatch": "Cannot join watch party: The content does not match the host's content.",
|
||||
"episodeMismatch": "Cannot join watch party: You are watching a different episode than the host.",
|
||||
"validating": "Validating watch party...",
|
||||
"linkCopied": "Copied!"
|
||||
"linkCopied": "Copied!",
|
||||
"backendRequirement": "All users must use the same backend server",
|
||||
"activeBackend": "Active Backend: {{backend}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
223
src/components/form/BackendSelector.tsx
Normal file
223
src/components/form/BackendSelector.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MetaResponse, getBackendMeta } from "@/backend/accounts/meta";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { TextInputControl } from "@/components/text-inputs/TextInputControl";
|
||||
|
||||
interface BackendOption {
|
||||
url: string;
|
||||
meta: MetaResponse | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
interface BackendSelectorProps {
|
||||
selectedUrl: string | null;
|
||||
onSelect: (url: string | null) => void;
|
||||
availableUrls: string[];
|
||||
showCustom?: boolean;
|
||||
}
|
||||
|
||||
function BackendOptionItem({
|
||||
option,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
option: BackendOption;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hostname = option.url ? new URL(option.url).hostname : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"w-full p-4 rounded-lg border-2 transition-colors text-left tabbable",
|
||||
isSelected
|
||||
? "border-buttons-purple bg-buttons-purple/10"
|
||||
: "border-transparent bg-authentication-inputBg hover:bg-authentication-inputBg/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={classNames(
|
||||
"w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||
isSelected
|
||||
? "border-buttons-purple bg-buttons-purple"
|
||||
: "border-type-secondary",
|
||||
)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Icon icon={Icons.CHECKMARK} className="text-white text-xs" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{option.loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loading />
|
||||
<span className="text-type-secondary text-sm">
|
||||
{t("auth.backendSelection.selecting")}
|
||||
</span>
|
||||
</div>
|
||||
) : option.error ? (
|
||||
<div>
|
||||
<p className="text-white font-medium">{hostname}</p>
|
||||
<p className="text-type-secondary text-sm">{option.url}</p>
|
||||
</div>
|
||||
) : option.meta ? (
|
||||
<div>
|
||||
<p className="text-white font-medium">{option.meta.name}</p>
|
||||
<p className="text-type-secondary text-sm">{hostname}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-white font-medium">{hostname}</p>
|
||||
<p className="text-type-secondary text-sm">{option.url}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected ? (
|
||||
<span className="text-buttons-purple text-sm font-medium">
|
||||
{t("auth.backendSelection.active")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackendSelector({
|
||||
selectedUrl,
|
||||
onSelect,
|
||||
availableUrls,
|
||||
showCustom = true,
|
||||
}: BackendSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [backendOptions, setBackendOptions] = useState<BackendOption[]>([]);
|
||||
|
||||
// Initialize and fetch meta for backend options
|
||||
useEffect(() => {
|
||||
const fetchMetas = async () => {
|
||||
const options: BackendOption[] = availableUrls.map((url) => ({
|
||||
url,
|
||||
meta: null,
|
||||
loading: true,
|
||||
error: false,
|
||||
}));
|
||||
setBackendOptions(options);
|
||||
|
||||
const promises = options.map(async (option) => {
|
||||
try {
|
||||
const meta = await getBackendMeta(option.url);
|
||||
return { ...option, meta, loading: false, error: false };
|
||||
} catch {
|
||||
return { ...option, meta: null, loading: false, error: true };
|
||||
}
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
setBackendOptions(results);
|
||||
};
|
||||
|
||||
if (availableUrls.length > 0) {
|
||||
fetchMetas();
|
||||
}
|
||||
}, [availableUrls]);
|
||||
|
||||
const handleCustomUrlSelect = () => {
|
||||
if (customUrl.trim()) {
|
||||
let url = customUrl.trim();
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
onSelect(url);
|
||||
}
|
||||
};
|
||||
|
||||
const isCustomUrlSelected =
|
||||
customUrl &&
|
||||
selectedUrl === customUrl &&
|
||||
!availableUrls.includes(selectedUrl);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{backendOptions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{backendOptions.map((option) => (
|
||||
<BackendOptionItem
|
||||
key={option.url}
|
||||
option={option}
|
||||
isSelected={selectedUrl === option.url}
|
||||
onClick={() => onSelect(option.url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showCustom && (
|
||||
<div
|
||||
className={classNames(
|
||||
"w-full p-4 rounded-lg border-2 transition-colors",
|
||||
isCustomUrlSelected
|
||||
? "border-buttons-purple bg-buttons-purple/10"
|
||||
: "border-transparent bg-authentication-inputBg",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={classNames(
|
||||
"w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||
isCustomUrlSelected
|
||||
? "border-buttons-purple bg-buttons-purple"
|
||||
: "border-type-secondary",
|
||||
)}
|
||||
>
|
||||
{isCustomUrlSelected ? (
|
||||
<Icon icon={Icons.CHECKMARK} className="text-white text-xs" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">
|
||||
{t("auth.backendSelection.customBackend")}
|
||||
</p>
|
||||
</div>
|
||||
{isCustomUrlSelected ? (
|
||||
<span className="text-buttons-purple text-sm font-medium">
|
||||
{t("auth.backendSelection.active")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[1fr,auto] items-center gap-2">
|
||||
<TextInputControl
|
||||
value={customUrl}
|
||||
onChange={setCustomUrl}
|
||||
placeholder={
|
||||
t("auth.backendSelection.customBackendPlaceholder") ??
|
||||
undefined
|
||||
}
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={handleCustomUrlSelect}
|
||||
disabled={!customUrl.trim()}
|
||||
>
|
||||
{t("auth.backendSelection.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useWatchPartySync } from "@/hooks/useWatchPartySync";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { getProgressPercentage } from "@/stores/progress";
|
||||
|
|
@ -15,6 +16,8 @@ export function WatchPartyStatus() {
|
|||
const [showNotification, setShowNotification] = useState(false);
|
||||
const [lastUserCount, setLastUserCount] = useState(1);
|
||||
const account = useAuthStore((s) => s.account);
|
||||
const backendUrl = useBackendUrl();
|
||||
const backendHostname = backendUrl ? new URL(backendUrl).hostname : null;
|
||||
|
||||
const {
|
||||
roomUsers,
|
||||
|
|
@ -70,6 +73,11 @@ export function WatchPartyStatus() {
|
|||
{roomCode}
|
||||
</span>
|
||||
</div>
|
||||
{backendHostname && (
|
||||
<div className="w-full text-xs text-type-secondary text-center">
|
||||
{t("watchParty.activeBackend", { backend: backendHostname })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full text-type-secondary flex justify-between items-center space-x-2">
|
||||
<div className="cursor-pointer" onClick={handleToggleExpanded}>
|
||||
|
|
|
|||
|
|
@ -220,7 +220,17 @@ export function WatchPartyView({ id }: { id: string }) {
|
|||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-center">
|
||||
<div className="text-center space-y-2">
|
||||
<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">
|
||||
<span className="text-xs">
|
||||
{t("watchParty.backendRequirement")}
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{t("watchParty.activeBackend", {
|
||||
backend: backendUrl || "Unknown",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Trans
|
||||
i18nKey={
|
||||
isHost ? "watchParty.isHost" : "watchParty.isGuest"
|
||||
|
|
|
|||
|
|
@ -3,5 +3,10 @@ import { useAuthStore } from "@/stores/auth";
|
|||
|
||||
export function useBackendUrl(): string | null {
|
||||
const backendUrl = useAuthStore((s) => s.backendUrl);
|
||||
return backendUrl ?? conf().BACKEND_URL;
|
||||
const config = conf();
|
||||
return (
|
||||
backendUrl ??
|
||||
config.BACKEND_URL ??
|
||||
(config.BACKEND_URLS.length > 0 ? config.BACKEND_URLS[0] : null)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,92 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { BackendSelector } from "@/components/form/BackendSelector";
|
||||
import {
|
||||
LargeCard,
|
||||
LargeCardButtons,
|
||||
LargeCardText,
|
||||
} from "@/components/layout/LargeCard";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [showBackendSelection, setShowBackendSelection] = useState(true);
|
||||
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||
const config = conf();
|
||||
const availableBackends =
|
||||
config.BACKEND_URLS.length > 0
|
||||
? config.BACKEND_URLS
|
||||
: config.BACKEND_URL
|
||||
? [config.BACKEND_URL]
|
||||
: [];
|
||||
|
||||
// If there's only one backend and user hasn't selected a custom one, auto-select it
|
||||
const currentBackendUrl = useAuthStore((s) => s.backendUrl);
|
||||
const defaultBackend =
|
||||
currentBackendUrl ??
|
||||
(availableBackends.length === 1 ? availableBackends[0] : null);
|
||||
|
||||
const handleBackendSelect = (url: string | null) => {
|
||||
setSelectedBackendUrl(url);
|
||||
if (url) {
|
||||
setBackendUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedBackendUrl || defaultBackend) {
|
||||
if (selectedBackendUrl) {
|
||||
setBackendUrl(selectedBackendUrl);
|
||||
} else if (defaultBackend) {
|
||||
setBackendUrl(defaultBackend);
|
||||
}
|
||||
setShowBackendSelection(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<PageTitle subpage k="global.pages.login" />
|
||||
<LoginFormPart
|
||||
onLogin={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
{showBackendSelection &&
|
||||
(availableBackends.length > 1 || !defaultBackend) ? (
|
||||
<LargeCard>
|
||||
<LargeCardText title={t("auth.backendSelection.title")}>
|
||||
{t("auth.backendSelection.description")}
|
||||
</LargeCardText>
|
||||
<BackendSelector
|
||||
selectedUrl={selectedBackendUrl ?? defaultBackend}
|
||||
onSelect={handleBackendSelect}
|
||||
availableUrls={availableBackends}
|
||||
showCustom
|
||||
/>
|
||||
<LargeCardButtons>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={handleContinue}
|
||||
disabled={!selectedBackendUrl && !defaultBackend}
|
||||
>
|
||||
{t("auth.register.information.next")}
|
||||
</Button>
|
||||
</LargeCardButtons>
|
||||
</LargeCard>
|
||||
) : (
|
||||
<LoginFormPart
|
||||
onLogin={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { useState } from "react";
|
||||
import { GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { MetaResponse } from "@/backend/accounts/meta";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { BackendSelector } from "@/components/form/BackendSelector";
|
||||
import {
|
||||
LargeCard,
|
||||
LargeCardButtons,
|
||||
LargeCardText,
|
||||
} from "@/components/layout/LargeCard";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import {
|
||||
AccountCreatePart,
|
||||
|
|
@ -12,6 +20,8 @@ import { PassphraseGeneratePart } from "@/pages/parts/auth/PassphraseGeneratePar
|
|||
import { TrustBackendPart } from "@/pages/parts/auth/TrustBackendPart";
|
||||
import { VerifyPassphrase } from "@/pages/parts/auth/VerifyPassphrasePart";
|
||||
import { PageTitle } from "@/pages/parts/util/PageTitle";
|
||||
import { conf } from "@/setup/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
function CaptchaProvider(props: {
|
||||
siteKey: string | null;
|
||||
|
|
@ -27,17 +37,63 @@ function CaptchaProvider(props: {
|
|||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(0);
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(-1);
|
||||
const [mnemonic, setMnemonic] = useState<null | string>(null);
|
||||
const [account, setAccount] = useState<null | AccountProfile>(null);
|
||||
const [siteKey, setSiteKey] = useState<string | null>(null);
|
||||
const [selectedBackendUrl, setSelectedBackendUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const setBackendUrl = useAuthStore((s) => s.setBackendUrl);
|
||||
const config = conf();
|
||||
const availableBackends =
|
||||
config.BACKEND_URLS.length > 0
|
||||
? config.BACKEND_URLS
|
||||
: config.BACKEND_URL
|
||||
? [config.BACKEND_URL]
|
||||
: [];
|
||||
|
||||
const handleBackendSelect = (url: string | null) => {
|
||||
setSelectedBackendUrl(url);
|
||||
if (url) {
|
||||
setBackendUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CaptchaProvider siteKey={siteKey}>
|
||||
<SubPageLayout>
|
||||
<PageTitle subpage k="global.pages.register" />
|
||||
{step === -1 ? (
|
||||
<LargeCard>
|
||||
<LargeCardText title={t("auth.backendSelection.title")}>
|
||||
{t("auth.backendSelection.description")}
|
||||
</LargeCardText>
|
||||
<BackendSelector
|
||||
selectedUrl={selectedBackendUrl}
|
||||
onSelect={handleBackendSelect}
|
||||
availableUrls={availableBackends}
|
||||
showCustom
|
||||
/>
|
||||
<LargeCardButtons>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={() => {
|
||||
if (selectedBackendUrl) {
|
||||
setStep(0);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedBackendUrl}
|
||||
>
|
||||
{t("auth.register.information.next")}
|
||||
</Button>
|
||||
</LargeCardButtons>
|
||||
</LargeCard>
|
||||
) : null}
|
||||
{step === 0 ? (
|
||||
<TrustBackendPart
|
||||
backendUrl={selectedBackendUrl}
|
||||
onNext={(meta: MetaResponse) => {
|
||||
setSiteKey(
|
||||
meta.hasCaptcha && meta.captchaClientKey
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ import { Button } from "@/components/buttons/Button";
|
|||
import { SearchBarInput } from "@/components/form/SearchBar";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
|
||||
import { UserIcons } from "@/components/UserIcon";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
|
||||
import { Transition } from "@/components/utils/Transition";
|
||||
import { useAuth } from "@/hooks/auth/useAuth";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
|
|
@ -168,6 +169,10 @@ export function SettingsPage() {
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const prevCategoryRef = useRef<string | null>(null);
|
||||
const backendChangeModal = useModal("settings-backend-change-confirmation");
|
||||
const [pendingBackendChange, setPendingBackendChange] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
|
|
@ -730,25 +735,32 @@ export function SettingsPage() {
|
|||
updateProfile(state.profile.state);
|
||||
}
|
||||
|
||||
// when backend url gets changed, log the user out first
|
||||
// when backend url gets changed, show confirmation and log the user out (only if logged in)
|
||||
if (state.backendUrl.changed) {
|
||||
await logout();
|
||||
|
||||
let url = state.backendUrl.state;
|
||||
if (url && !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
if (account) {
|
||||
// User is logged in - show confirmation
|
||||
setPendingBackendChange(url);
|
||||
backendChangeModal.show();
|
||||
return;
|
||||
}
|
||||
// User is not logged in - just update without confirmation
|
||||
setBackendUrl(url);
|
||||
}
|
||||
}, [
|
||||
account,
|
||||
backendUrl,
|
||||
backendChangeModal,
|
||||
setPendingBackendChange,
|
||||
state,
|
||||
setBackendUrl,
|
||||
setEnableThumbnails,
|
||||
setFebboxKey,
|
||||
setdebridToken,
|
||||
setdebridService,
|
||||
state,
|
||||
setEnableAutoplay,
|
||||
setEnableSkipCredits,
|
||||
setEnableDiscover,
|
||||
|
|
@ -766,8 +778,6 @@ export function SettingsPage() {
|
|||
updateDeviceName,
|
||||
updateProfile,
|
||||
updateNickname,
|
||||
logout,
|
||||
setBackendUrl,
|
||||
setProxyTmdb,
|
||||
setEnableCarouselView,
|
||||
setEnableMinimalCards,
|
||||
|
|
@ -948,6 +958,43 @@ export function SettingsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
{account && (
|
||||
<Modal id={backendChangeModal.id}>
|
||||
<ModalCard>
|
||||
<Heading2 className="!mt-0 !mb-4">
|
||||
{t("settings.connections.server.changeWarningTitle")}
|
||||
</Heading2>
|
||||
<Paragraph className="!mt-1 !mb-6">
|
||||
{t("settings.connections.server.changeWarning")}
|
||||
</Paragraph>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
theme="secondary"
|
||||
onClick={() => {
|
||||
backendChangeModal.hide();
|
||||
setPendingBackendChange(null);
|
||||
state.backendUrl.set(backendUrlSetting);
|
||||
}}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
theme="purple"
|
||||
onClick={async () => {
|
||||
backendChangeModal.hide();
|
||||
if (pendingBackendChange !== null) {
|
||||
await logout();
|
||||
setBackendUrl(pendingBackendChange);
|
||||
setPendingBackendChange(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("actions.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
)}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import { MwLink } from "@/components/text/Link";
|
|||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
|
||||
interface TrustBackendPartProps {
|
||||
backendUrl?: string | null;
|
||||
onNext?: (meta: MetaResponse) => void;
|
||||
}
|
||||
|
||||
export function TrustBackendPart(props: TrustBackendPartProps) {
|
||||
const navigate = useNavigate();
|
||||
const backendUrl = useBackendUrl();
|
||||
const defaultBackendUrl = useBackendUrl();
|
||||
const backendUrl = props.backendUrl ?? defaultBackendUrl;
|
||||
const hostname = useMemo(
|
||||
() => (backendUrl ? new URL(backendUrl).hostname : undefined),
|
||||
[backendUrl],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Toggle } from "@/components/buttons/Toggle";
|
||||
import { BackendSelector } from "@/components/form/BackendSelector";
|
||||
import { Dropdown } from "@/components/form/Dropdown";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SettingsCard } from "@/components/layout/SettingsCard";
|
||||
|
|
@ -184,9 +185,44 @@ function ProxyEdit({
|
|||
function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore();
|
||||
const config = conf();
|
||||
const availableBackends =
|
||||
config.BACKEND_URLS.length > 0
|
||||
? config.BACKEND_URLS
|
||||
: config.BACKEND_URL
|
||||
? [config.BACKEND_URL]
|
||||
: [];
|
||||
const currentBackendUrl =
|
||||
backendUrl ?? (availableBackends.length > 0 ? availableBackends[0] : null);
|
||||
const [pendingBackendUrl, setPendingBackendUrl] = useState<string | null>(
|
||||
currentBackendUrl,
|
||||
);
|
||||
const confirmationModal = useModal("backend-change-confirmation");
|
||||
|
||||
const handleBackendSelect = (url: string | null) => {
|
||||
if (!user.account) {
|
||||
// No account - just update without confirmation
|
||||
setBackendUrl(url);
|
||||
setPendingBackendUrl(url);
|
||||
} else if (url !== currentBackendUrl) {
|
||||
// User is logged in and changing backend - show confirmation
|
||||
setPendingBackendUrl(url);
|
||||
confirmationModal.show();
|
||||
} else {
|
||||
// Same backend - just update
|
||||
setBackendUrl(url);
|
||||
setPendingBackendUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmChange = () => {
|
||||
setBackendUrl(pendingBackendUrl);
|
||||
confirmationModal.hide();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<>
|
||||
<SettingsCard>
|
||||
<div className="my-3">
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.connections.server.label")}
|
||||
|
|
@ -211,27 +247,50 @@ function BackendEdit({ backendUrl, setBackendUrl }: BackendEditProps) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
onClick={() => setBackendUrl((s) => (s === null ? "" : null))}
|
||||
enabled={backendUrl !== null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{backendUrl !== null ? (
|
||||
<>
|
||||
<Divider marginClass="my-6 px-8 box-content -mx-8" />
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.connections.server.urlLabel")}
|
||||
</p>
|
||||
<AuthInputBox
|
||||
onChange={setBackendUrl}
|
||||
value={backendUrl ?? ""}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
{(availableBackends.length > 0 || currentBackendUrl) && (
|
||||
<>
|
||||
<Divider marginClass="my-6 px-8 box-content -mx-8" />
|
||||
<p className="text-white font-bold mb-3">
|
||||
{t("settings.connections.server.selectBackend")}
|
||||
</p>
|
||||
{availableBackends.length > 0 ? (
|
||||
<BackendSelector
|
||||
selectedUrl={currentBackendUrl}
|
||||
onSelect={handleBackendSelect}
|
||||
availableUrls={availableBackends}
|
||||
showCustom
|
||||
/>
|
||||
) : (
|
||||
<AuthInputBox
|
||||
onChange={setBackendUrl}
|
||||
value={backendUrl ?? ""}
|
||||
placeholder="https://"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{user.account && (
|
||||
<Modal id={confirmationModal.id}>
|
||||
<ModalCard>
|
||||
<Heading2 className="!mt-0 !mb-4">
|
||||
{t("settings.connections.server.changeWarningTitle")}
|
||||
</Heading2>
|
||||
<Paragraph className="!mt-1 !mb-6">
|
||||
{t("settings.connections.server.changeWarning")}
|
||||
</Paragraph>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button theme="secondary" onClick={confirmationModal.hide}>
|
||||
{t("settings.connections.server.cancel")}
|
||||
</Button>
|
||||
<Button theme="purple" onClick={handleConfirmChange}>
|
||||
{t("settings.connections.server.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalCard>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface RuntimeConfig {
|
|||
PROXY_URLS: string[];
|
||||
M3U8_PROXY_URLS: string[];
|
||||
BACKEND_URL: string | null;
|
||||
BACKEND_URLS: string[];
|
||||
DISALLOWED_IDS: string[];
|
||||
CDN_REPLACEMENTS: Array<string[]>;
|
||||
HAS_ONBOARDING: boolean;
|
||||
|
|
@ -137,7 +138,24 @@ export function conf(): RuntimeConfig {
|
|||
"https://docs.pstream.mov/extension",
|
||||
),
|
||||
ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"),
|
||||
BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL),
|
||||
BACKEND_URLS: getKey("BACKEND_URL", BACKEND_URL)
|
||||
? getKey("BACKEND_URL", BACKEND_URL)
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: [],
|
||||
BACKEND_URL: (() => {
|
||||
const backendUrlValue = getKey("BACKEND_URL", BACKEND_URL);
|
||||
if (!backendUrlValue) return backendUrlValue;
|
||||
if (backendUrlValue.includes(",")) {
|
||||
const urls = backendUrlValue
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
return urls.length > 0 ? urls[0] : backendUrlValue;
|
||||
}
|
||||
return backendUrlValue;
|
||||
})(),
|
||||
TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"),
|
||||
PROXY_URLS: getKey("CORS_PROXY_URL", "")
|
||||
.split(",")
|
||||
|
|
|
|||
Loading…
Reference in a new issue