From 8c6755122f2d2532b482f9a491f647056c7ef35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linkr=C3=A9dible?= Date: Sun, 11 Jan 2026 00:25:18 +0100 Subject: [PATCH 1/2] feat: add multi-account switching support Allow users to switch between multiple Stremio accounts stored locally without logging out. New AccountManager handles localStorage persistence with settings, ProfilePicker component provides account selection UI with manage/delete mode. NavMenu includes Change Profile and Log out actions. Handles token expiration gracefully by removing invalid accounts and prompting re-login. --- src/App/withProtectedRoutes.js | 4 +- src/common/AccountManager.js | 60 +++++ .../NavMenu/NavMenuContent.js | 43 +++- .../HorizontalNavBar/NavMenu/styles.less | 37 ++- src/components/ProfilePicker/ProfilePicker.js | 136 ++++++++++++ src/components/ProfilePicker/styles.less | 210 ++++++++++++++++++ src/routes/Intro/Intro.js | 93 +++++++- 7 files changed, 560 insertions(+), 23 deletions(-) create mode 100644 src/common/AccountManager.js create mode 100644 src/components/ProfilePicker/ProfilePicker.js create mode 100644 src/components/ProfilePicker/styles.less diff --git a/src/App/withProtectedRoutes.js b/src/App/withProtectedRoutes.js index a16e0c0c7..af32f3e8e 100644 --- a/src/App/withProtectedRoutes.js +++ b/src/App/withProtectedRoutes.js @@ -15,7 +15,9 @@ const withProtectedRoutes = (Component) => { previousAuthRef.current = profile.auth; }, [profile]); const onRouteChange = React.useCallback((routeConfig) => { - if (profile.auth !== null && routeConfig.component === Intro) { + // Allow access to /intro when ?profiles=true (for profile switching) + const urlParams = new URLSearchParams(window.location.hash.split('?')[1]); + if (profile.auth !== null && routeConfig.component === Intro && urlParams.get('profiles') !== 'true') { window.location.replace('#/'); return true; } diff --git a/src/common/AccountManager.js b/src/common/AccountManager.js new file mode 100644 index 000000000..14c06b728 --- /dev/null +++ b/src/common/AccountManager.js @@ -0,0 +1,60 @@ +const STORAGE_KEY = 'stremio-multi-accounts'; + +const AccountManager = { + getAccounts: () => { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; + } catch (_e) { + return []; + } + }, + + addAccount: (profile, authKey, settings = null) => { + const accounts = AccountManager.getAccounts(); + const existing = accounts.find((a) => a.email === profile.email); + const filtered = accounts.filter((a) => a.email !== profile.email); + + const newAccount = { + email: profile.email, + avatar: profile.avatar, + authKey: authKey, + settings: settings || existing?.settings || null, + lastActive: Date.now() + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount])); + }, + + updateSettings: (email, settings) => { + const accounts = AccountManager.getAccounts(); + const updated = accounts.map((a) => + a.email === email ? { ...a, settings } : a + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + }, + + getSettings: (email) => { + const accounts = AccountManager.getAccounts(); + const account = accounts.find((a) => a.email === email); + return account?.settings || null; + }, + + removeAccount: (email) => { + const accounts = AccountManager.getAccounts(); + const filtered = accounts.filter((a) => a.email !== email); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + }, + + switchTo: (email) => { + const accounts = AccountManager.getAccounts(); + const target = accounts.find((a) => a.email === email); + if (target && target.authKey) { + accounts.forEach((a) => a.lastActive = a.email === email ? Date.now() : a.lastActive); + localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts)); + return target; + } + return null; + } +}; + +module.exports = AccountManager; diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index de0a02212..77d6527db 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -5,6 +5,7 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const { useTranslation } = require('react-i18next'); const { default: Icon } = require('@stremio/stremio-icons/react'); +const AccountManager = require('stremio/common/AccountManager'); const { useServices } = require('stremio/services'); const { Button } = require('stremio/components'); const { default: useFullscreen } = require('stremio/common/useFullscreen'); @@ -29,14 +30,32 @@ const NavMenuContent = ({ onClick }) => { profile.settings.streamingServerWarningDismissed.getTime() > Date.now() ); }, [profile.settings, streamingServer.settings]); + const onChangeProfile = React.useCallback(() => { + if (profile.auth && profile.auth.user && profile.auth.key) { + AccountManager.addAccount(profile.auth.user, profile.auth.key, profile.settings); + } + window.location.hash = '#/intro?profiles=true'; + }, [profile.auth, profile.settings]); const logoutButtonOnClick = React.useCallback(() => { + const currentEmail = profile.auth?.user?.email; + const accounts = AccountManager.getAccounts(); + const remainingAccounts = accounts.filter((acc) => acc.email !== currentEmail); + + if (currentEmail) { + AccountManager.removeAccount(currentEmail); + } + core.transport.dispatch({ action: 'Ctx', args: { action: 'Logout' } }); - }, []); + + if (remainingAccounts.length > 0) { + window.location.hash = '#/intro?profiles=true'; + } + }, [profile.auth]); const onPlayMagnetLinkClick = React.useCallback(async () => { try { const clipboardText = await navigator.clipboard.readText(); @@ -64,9 +83,25 @@ const NavMenuContent = ({ onClick }) => {
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
- +
+ { + profile.auth !== null ? + + : + null + } + { + profile.auth !== null ? +
/
+ : + null + } + +
{ diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less b/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less index 31b997d38..01bcf5eff 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/styles.less @@ -61,22 +61,35 @@ } } - .logout-button-container { + .user-actions-container { flex: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; - &:hover, &:focus { - outline: none; - - .logout-label { - text-decoration: underline; - } + .user-actions-separator { + color: var(--primary-foreground-color); + opacity: 0.5; + font-size: 0.9rem; } - .logout-label { - flex: 1; - font-size: 0.9rem; - font-weight: 500; - color: var(--primary-foreground-color); + .user-action-button { + flex: none; + + &:hover, &:focus { + outline: none; + + .user-action-label { + text-decoration: underline; + } + } + + .user-action-label { + font-size: 0.9rem; + font-weight: 500; + color: var(--primary-foreground-color); + } } } } diff --git a/src/components/ProfilePicker/ProfilePicker.js b/src/components/ProfilePicker/ProfilePicker.js new file mode 100644 index 000000000..be912c216 --- /dev/null +++ b/src/components/ProfilePicker/ProfilePicker.js @@ -0,0 +1,136 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const React = require('react'); +const { useTranslation } = require('react-i18next'); +const PropTypes = require('prop-types'); +const classnames = require('classnames'); +const { default: Icon } = require('@stremio/stremio-icons/react'); +const { Image } = require('stremio/components'); +const styles = require('./styles.less'); + +const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => { + const { t } = useTranslation(); + const displayName = profile.email + ? profile.email.split('@')[0] + : t('USER', { defaultValue: 'User' }); + + const handleClick = React.useCallback((event) => { + event.stopPropagation(); + if (!isEditing) { + onSelect(profile); + } + }, [isEditing, onSelect, profile]); + + const handleDelete = React.useCallback((event) => { + event.stopPropagation(); + onDelete(profile); + }, [onDelete, profile]); + + return ( +
+
+ {profile.email} + {isEditing && ( + + )} +
+ {displayName} +
+ ); +}; + +ProfileItem.propTypes = { + profile: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +const AddProfileButton = ({ onClick }) => { + const { t } = useTranslation(); + return ( +
+
+ +
+ {t('ADD', { defaultValue: 'Add' })} +
+ ); +}; + +AddProfileButton.propTypes = { + onClick: PropTypes.func.isRequired +}; + +const ProfilePicker = ({ profiles = [], onSelectProfile, onAddProfile, onDeleteProfile }) => { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = React.useState(false); + + const toggleEditMode = React.useCallback(() => { + setIsEditing((prev) => !prev); + }, []); + + const handleDelete = React.useCallback((profile) => { + if (onDeleteProfile) { + onDeleteProfile(profile); + } + }, [onDeleteProfile]); + + const title = isEditing + ? t('MANAGE_PROFILES', { defaultValue: 'Manage Profiles' }) + : t('WHO_IS_WATCHING', { defaultValue: 'Who is watching?' }); + + const buttonText = isEditing + ? t('DONE', { defaultValue: 'DONE' }) + : t('MANAGE_PROFILES', { defaultValue: 'MANAGE PROFILES' }); + + return ( +
+
+

{title}

+ +
+ {profiles.map((profile, index) => ( + + ))} + {!isEditing && } +
+ + +
+
+ ); +}; + +ProfilePicker.propTypes = { + profiles: PropTypes.array, + onSelectProfile: PropTypes.func, + onAddProfile: PropTypes.func, + onDeleteProfile: PropTypes.func +}; + +module.exports = ProfilePicker; diff --git a/src/components/ProfilePicker/styles.less b/src/components/ProfilePicker/styles.less new file mode 100644 index 000000000..8ed14fb79 --- /dev/null +++ b/src/components/ProfilePicker/styles.less @@ -0,0 +1,210 @@ +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.profile-picker-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #141414; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + font-family: 'PlusJakartaSans', sans-serif; + user-select: none; +} + +.profile-picker-container { + text-align: center; + animation: fadeIn 0.4s ease-in-out; + overflow: visible; +} + +.title { + color: #fff; + font-size: 3.5rem; + font-weight: 500; + margin-bottom: 2rem; + user-select: none; + transition: all 0.3s ease; +} + +.profiles-grid { + display: flex; + gap: 2rem; + justify-content: center; + flex-wrap: wrap; + min-height: 200px; + overflow: visible; +} + +.profile-item { + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + pointer-events: auto; + transition: transform 0.2s ease; + overflow: visible; + padding: 10px; + + &:hover { + .avatar-wrapper { + border-color: #fff; + transform: scale(1.05); + } + .profile-name { + color: #fff; + } + } + + &:active { + .avatar-wrapper { + transform: scale(0.98); + } + } + + &.editing { + cursor: default; + + &:hover { + .avatar-wrapper { + transform: none; + border-color: transparent; + } + } + + .avatar-wrapper { + opacity: 0.7; + } + } +} + +.avatar-wrapper { + width: 10vw; + height: 10vw; + min-width: 84px; + min-height: 84px; + max-width: 200px; + max-height: 200px; + border-radius: 50%; + overflow: visible; + margin-bottom: 1rem; + border: 3px solid transparent; + transition: border-color 0.2s, transform 0.2s, opacity 0.2s; + position: relative; + + &.add-profile-wrapper { + display: flex; + align-items: center; + justify-content: center; + background-color: transparent; + border: 3px solid #333; + + &:hover { + background-color: #fff; + border-color: #fff; + .add-icon { + fill: #000; + } + } + } +} + +.avatar { + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + border-radius: 50%; +} + +.delete-button { + position: absolute; + top: -8px; + right: -8px; + width: 32px; + height: 32px; + border-radius: 50%; + background-color: #e50914; + border: 2px solid #141414; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease, background-color 0.2s ease; + z-index: 10; + padding: 0; + + &:hover { + transform: scale(1.15); + background-color: #ff1f2a; + } + + &:active { + transform: scale(0.95); + } +} + +.delete-icon { + width: 16px; + height: 16px; + fill: #fff; + pointer-events: none; +} + +.add-icon { + width: 50%; + height: 50%; + fill: #808080; + transition: fill 0.2s; + pointer-events: none; +} + +.profile-name { + color: #808080; + font-size: 1.2rem; + transition: color 0.2s; + max-width: 10vw; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + pointer-events: none; +} + +.manage-button { + margin-top: 3rem; + background: transparent; + border: 1px solid #808080; + color: #808080; + padding: 0.5rem 1.5rem; + font-size: 1.1rem; + letter-spacing: 2px; + cursor: pointer; + display: inline-block; + user-select: none; + transition: all 0.2s ease; + + &:hover { + color: #fff; + border-color: #fff; + } + + &.done-button { + background-color: #fff; + color: #141414; + border-color: #fff; + + &:hover { + background-color: #e5e5e5; + border-color: #e5e5e5; + } + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(1.1); } + to { opacity: 1; transform: scale(1); } +} \ No newline at end of file diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index f04302fc7..e598543d1 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -15,6 +15,8 @@ const useFacebookLogin = require('./useFacebookLogin'); const { default: useAppleLogin } = require('./useAppleLogin'); const styles = require('./styles'); +const AccountManager = require('stremio/common/AccountManager'); +const ProfilePicker = require('stremio/components/ProfilePicker/ProfilePicker'); const SIGNUP_FORM = 'signup'; const LOGIN_FORM = 'login'; @@ -249,8 +251,10 @@ const Intro = ({ queryParams }) => { dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' }); }, []); const switchFormOnClick = React.useCallback(() => { - const queryParams = new URLSearchParams([['form', state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM]]); - window.location = `#/intro?${queryParams.toString()}`; + const newForm = state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM; + const currentParams = new URLSearchParams(window.location.hash.split('?')[1]); + currentParams.set('form', newForm); + window.location = `#/intro?${currentParams.toString()}`; }, [state.form]); React.useEffect(() => { if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) { @@ -263,14 +267,31 @@ const Intro = ({ queryParams }) => { } }, [state.error]); React.useEffect(() => { - if (routeFocused) { + if (routeFocused && !showProfiles && emailRef.current) { emailRef.current.focus(); } - }, [state.form, routeFocused]); + }, [state.form, routeFocused, showProfiles]); + const [savedAccounts, setSavedAccounts] = React.useState(() => AccountManager.getAccounts()); + const [pendingProfile, setPendingProfile] = React.useState(null); + const showProfilesParam = queryParams.get('profiles') === 'true'; + const [showProfiles, setShowProfiles] = React.useState(showProfilesParam || savedAccounts.length > 0); React.useEffect(() => { const onCoreEvent = ({ event, args }) => { switch (event) { case 'UserAuthenticated': { + if (args.auth && args.auth.key) { + AccountManager.addAccount(args.user, args.auth.key); + } + if (pendingProfile && pendingProfile.settings) { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: pendingProfile.settings + } + }); + } + setPendingProfile(null); closeLoaderModal(); if (routeFocused) { window.location = '#/'; @@ -280,8 +301,23 @@ const Intro = ({ queryParams }) => { case 'Error': { if (args.source.event === 'UserAuthenticated') { closeLoaderModal(); + if (pendingProfile) { + AccountManager.removeAccount(pendingProfile.email); + setSavedAccounts(AccountManager.getAccounts()); + dispatch({ type: 'set-form', form: LOGIN_FORM }); + dispatch({ + type: 'error', + error: t('SESSION_EXPIRED', { defaultValue: 'Session expired. Please log in again.' }) + }); + dispatch({ + type: 'change-credentials', + name: 'email', + value: pendingProfile.email + }); + setShowProfiles(false); + setPendingProfile(null); + } } - break; } } @@ -290,7 +326,52 @@ const Intro = ({ queryParams }) => { return () => { core.transport.off('CoreEvent', onCoreEvent); }; - }, [routeFocused]); + }, [routeFocused, pendingProfile, t]); + const onSelectProfile = React.useCallback((profile) => { + const account = AccountManager.switchTo(profile.email); + if (account && account.authKey) { + setPendingProfile(profile); + openLoaderModal(); + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'Authenticate', + args: { + type: 'LoginWithToken', + token: account.authKey + } + } + }); + } else { + dispatch({ + type: 'change-credentials', + name: 'email', + value: profile.email + }); + setShowProfiles(false); + } + }, [core, openLoaderModal]); + const onAddProfile = React.useCallback(() => { + setShowProfiles(false); + }, []); + const onDeleteProfile = React.useCallback((profile) => { + AccountManager.removeAccount(profile.email); + const updatedAccounts = AccountManager.getAccounts(); + setSavedAccounts(updatedAccounts); + if (updatedAccounts.length === 0) { + setShowProfiles(false); + } + }, []); + if (showProfiles && savedAccounts.length > 0) { + return ( + + ); + } return (
From 0d33ccd27191240b1549158d61f12c20df59cbce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Linkr=C3=A9dible?= Date: Sun, 11 Jan 2026 12:19:24 +0100 Subject: [PATCH 2/2] fix: improve code quality and UI consistency - AccountManager: add JSDoc documentation, wrap localStorage operations in try-catch, use immutable map instead of forEach mutation - ProfilePicker: convert div to button for accessibility, use PropTypes.shape for better type checking, replace hardcoded colors with CSS variables to match Stremio design system, fix overflow hidden on avatar-wrapper - Intro: fix race condition in onSelectProfile, use account.settings instead of stale profile.settings, add missing useCallback/useEffect dependencies, fix variable declaration order - NavMenuContent: add missing 'core' dependency to logoutButtonOnClick - Add copyright headers to new files --- src/common/AccountManager.js | 122 ++++++++++++++---- .../NavMenu/NavMenuContent.js | 2 +- src/components/ProfilePicker/ProfilePicker.js | 25 ++-- src/components/ProfilePicker/styles.less | 78 +++++++---- src/routes/Intro/Intro.js | 15 ++- 5 files changed, 176 insertions(+), 66 deletions(-) diff --git a/src/common/AccountManager.js b/src/common/AccountManager.js index 14c06b728..e378c13d0 100644 --- a/src/common/AccountManager.js +++ b/src/common/AccountManager.js @@ -1,6 +1,31 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +/** + * Storage key used for storing multi-account data in localStorage. + * @constant {string} + */ const STORAGE_KEY = 'stremio-multi-accounts'; +/** + * AccountManager module for managing multiple Stremio accounts. + * Stores account data in localStorage including email, avatar, authKey, and settings. + * + * @warning authKeys are stored in localStorage. While convenient for multi-account + * switching, this has security implications - anyone with access to the browser's + * localStorage can retrieve these tokens. + * + * @typedef {Object} Account + * @property {string} email - User's email address (unique identifier) + * @property {string} avatar - URL to user's avatar image + * @property {string} authKey - Authentication token for the account + * @property {Object|null} settings - User's application settings + * @property {number} lastActive - Timestamp of last account activity + */ const AccountManager = { + /** + * Retrieves all stored accounts from localStorage. + * @returns {Account[]} Array of account objects, empty array if none or on error + */ getAccounts: () => { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; @@ -9,49 +34,102 @@ const AccountManager = { } }, + /** + * Adds or updates an account in storage. + * If account with same email exists, it will be updated. + * @param {Object} profile - User profile object + * @param {string} profile.email - User's email + * @param {string} profile.avatar - User's avatar URL + * @param {string} authKey - Authentication token + * @param {Object|null} [settings=null] - Optional settings to store + */ addAccount: (profile, authKey, settings = null) => { - const accounts = AccountManager.getAccounts(); - const existing = accounts.find((a) => a.email === profile.email); - const filtered = accounts.filter((a) => a.email !== profile.email); + try { + const accounts = AccountManager.getAccounts(); + const existing = accounts.find((a) => a.email === profile.email); + const filtered = accounts.filter((a) => a.email !== profile.email); - const newAccount = { - email: profile.email, - avatar: profile.avatar, - authKey: authKey, - settings: settings || existing?.settings || null, - lastActive: Date.now() - }; + const newAccount = { + email: profile.email, + avatar: profile.avatar, + authKey: authKey, + settings: settings || existing?.settings || null, + lastActive: Date.now() + }; - localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount])); + localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount])); + } catch (_e) { + // localStorage may be full or disabled + } }, + /** + * Updates settings for a specific account. + * @param {string} email - Email of account to update + * @param {Object} settings - New settings object + */ updateSettings: (email, settings) => { - const accounts = AccountManager.getAccounts(); - const updated = accounts.map((a) => - a.email === email ? { ...a, settings } : a - ); - localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + try { + const accounts = AccountManager.getAccounts(); + const updated = accounts.map((a) => + a.email === email ? { ...a, settings } : a + ); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (_e) { + // localStorage may be full or disabled + } }, + /** + * Retrieves settings for a specific account. + * @param {string} email - Email of account + * @returns {Object|null} Settings object or null if not found + */ getSettings: (email) => { const accounts = AccountManager.getAccounts(); const account = accounts.find((a) => a.email === email); return account?.settings || null; }, + /** + * Removes an account from storage. + * @param {string} email - Email of account to remove + */ removeAccount: (email) => { - const accounts = AccountManager.getAccounts(); - const filtered = accounts.filter((a) => a.email !== email); - localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + try { + const accounts = AccountManager.getAccounts(); + const filtered = accounts.filter((a) => a.email !== email); + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + } catch (_e) { + // localStorage may be full or disabled + } }, + /** + * Switches to a specific account, updating lastActive timestamp. + * Uses immutable map operation to avoid mutating the original array. + * @param {string} email - Email of account to switch to + * @returns {Account|null} The account object if found and has authKey, null otherwise + */ switchTo: (email) => { const accounts = AccountManager.getAccounts(); const target = accounts.find((a) => a.email === email); if (target && target.authKey) { - accounts.forEach((a) => a.lastActive = a.email === email ? Date.now() : a.lastActive); - localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts)); - return target; + let updatedTarget = target; + const updatedAccounts = accounts.map((a) => { + if (a.email === email) { + const newAccount = { ...a, lastActive: Date.now() }; + updatedTarget = newAccount; + return newAccount; + } + return a; + }); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedAccounts)); + } catch (_e) { + // localStorage may be full or disabled + } + return updatedTarget; } return null; } diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index 77d6527db..b401dbe75 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -55,7 +55,7 @@ const NavMenuContent = ({ onClick }) => { if (remainingAccounts.length > 0) { window.location.hash = '#/intro?profiles=true'; } - }, [profile.auth]); + }, [profile.auth, core]); const onPlayMagnetLinkClick = React.useCallback(async () => { try { const clipboardText = await navigator.clipboard.readText(); diff --git a/src/components/ProfilePicker/ProfilePicker.js b/src/components/ProfilePicker/ProfilePicker.js index be912c216..f8f059feb 100644 --- a/src/components/ProfilePicker/ProfilePicker.js +++ b/src/components/ProfilePicker/ProfilePicker.js @@ -27,7 +27,8 @@ const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => { }, [onDelete, profile]); return ( -
@@ -48,26 +49,33 @@ const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => { )}
{displayName} -
+ ); }; ProfileItem.propTypes = { - profile: PropTypes.object.isRequired, + profile: PropTypes.shape({ + email: PropTypes.string, + avatar: PropTypes.string, + authKey: PropTypes.string, + settings: PropTypes.object, + lastActive: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) + }).isRequired, isEditing: PropTypes.bool, onSelect: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired }; const AddProfileButton = ({ onClick }) => { - const { t } = useTranslation(); return ( -
+
+ ); }; @@ -99,6 +107,7 @@ const ProfilePicker = ({ profiles = [], onSelectProfile, onAddProfile, onDeleteP return (
+

{title}

diff --git a/src/components/ProfilePicker/styles.less b/src/components/ProfilePicker/styles.less index 8ed14fb79..105ac5009 100644 --- a/src/components/ProfilePicker/styles.less +++ b/src/components/ProfilePicker/styles.less @@ -1,3 +1,5 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + @import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; .profile-picker-overlay { @@ -6,13 +8,28 @@ left: 0; width: 100vw; height: 100vh; - background-color: #141414; z-index: 10000; display: flex; align-items: center; justify-content: center; font-family: 'PlusJakartaSans', sans-serif; user-select: none; + overflow-y: auto; +} + +.background-container { + z-index: -1; + position: fixed; + top: -1rem; + bottom: -1rem; + left: -1rem; + right: -1rem; + background: url('/images/background_1.svg'), url('/images/background_2.svg'); + background-color: var(--primary-background-color); + background-position: bottom left, top right; + background-size: 53%, 54%; + background-repeat: no-repeat; + filter: blur(6rem); } .profile-picker-container { @@ -22,9 +39,9 @@ } .title { - color: #fff; - font-size: 3.5rem; - font-weight: 500; + color: var(--primary-foreground-color); + font-size: 3rem; + font-weight: 600; margin-bottom: 2rem; user-select: none; transition: all 0.3s ease; @@ -48,14 +65,17 @@ transition: transform 0.2s ease; overflow: visible; padding: 10px; + background: transparent; + border: none; + font-family: inherit; &:hover { .avatar-wrapper { - border-color: #fff; + border-color: var(--primary-foreground-color); transform: scale(1.05); } .profile-name { - color: #fff; + color: var(--primary-foreground-color); } } @@ -89,7 +109,7 @@ max-width: 200px; max-height: 200px; border-radius: 50%; - overflow: visible; + overflow: hidden; margin-bottom: 1rem; border: 3px solid transparent; transition: border-color 0.2s, transform 0.2s, opacity 0.2s; @@ -99,14 +119,14 @@ display: flex; align-items: center; justify-content: center; - background-color: transparent; - border: 3px solid #333; + background-color: var(--overlay-color); + border: 3px solid var(--secondary-foreground-color); &:hover { - background-color: #fff; - border-color: #fff; + background-color: var(--primary-foreground-color); + border-color: var(--primary-foreground-color); .add-icon { - fill: #000; + fill: var(--primary-background-color); } } } @@ -127,8 +147,8 @@ width: 32px; height: 32px; border-radius: 50%; - background-color: #e50914; - border: 2px solid #141414; + background-color: var(--signal-error-color); + border: 2px solid var(--primary-background-color); cursor: pointer; display: flex; align-items: center; @@ -139,7 +159,6 @@ &:hover { transform: scale(1.15); - background-color: #ff1f2a; } &:active { @@ -150,23 +169,25 @@ .delete-icon { width: 16px; height: 16px; - fill: #fff; + fill: var(--primary-foreground-color); pointer-events: none; } .add-icon { width: 50%; height: 50%; - fill: #808080; + fill: var(--primary-foreground-color); transition: fill 0.2s; pointer-events: none; } .profile-name { - color: #808080; + color: var(--primary-foreground-color); font-size: 1.2rem; + font-weight: 500; transition: color 0.2s; - max-width: 10vw; + max-width: 12vw; + min-width: 100px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -177,8 +198,8 @@ .manage-button { margin-top: 3rem; background: transparent; - border: 1px solid #808080; - color: #808080; + border: 1px solid var(--primary-foreground-color); + color: var(--primary-foreground-color); padding: 0.5rem 1.5rem; font-size: 1.1rem; letter-spacing: 2px; @@ -186,20 +207,21 @@ display: inline-block; user-select: none; transition: all 0.2s ease; + border-radius: 3.5rem; + opacity: 0.7; &:hover { - color: #fff; - border-color: #fff; + opacity: 1; } &.done-button { - background-color: #fff; - color: #141414; - border-color: #fff; + background-color: var(--primary-accent-color); + color: var(--primary-foreground-color); + border-color: var(--primary-accent-color); + opacity: 1; &:hover { - background-color: #e5e5e5; - border-color: #e5e5e5; + opacity: 0.9; } } } diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js index e598543d1..869e25665 100644 --- a/src/routes/Intro/Intro.js +++ b/src/routes/Intro/Intro.js @@ -266,15 +266,15 @@ const Intro = ({ queryParams }) => { errorRef.current.scrollIntoView(); } }, [state.error]); + const [savedAccounts, setSavedAccounts] = React.useState(() => AccountManager.getAccounts()); + const [pendingProfile, setPendingProfile] = React.useState(null); + const showProfilesParam = queryParams.get('profiles') === 'true'; + const [showProfiles, setShowProfiles] = React.useState(showProfilesParam || savedAccounts.length > 0); React.useEffect(() => { if (routeFocused && !showProfiles && emailRef.current) { emailRef.current.focus(); } }, [state.form, routeFocused, showProfiles]); - const [savedAccounts, setSavedAccounts] = React.useState(() => AccountManager.getAccounts()); - const [pendingProfile, setPendingProfile] = React.useState(null); - const showProfilesParam = queryParams.get('profiles') === 'true'; - const [showProfiles, setShowProfiles] = React.useState(showProfilesParam || savedAccounts.length > 0); React.useEffect(() => { const onCoreEvent = ({ event, args }) => { switch (event) { @@ -326,11 +326,12 @@ const Intro = ({ queryParams }) => { return () => { core.transport.off('CoreEvent', onCoreEvent); }; - }, [routeFocused, pendingProfile, t]); + }, [routeFocused, pendingProfile, t, closeLoaderModal, dispatch, core]); const onSelectProfile = React.useCallback((profile) => { + if (pendingProfile) return; const account = AccountManager.switchTo(profile.email); if (account && account.authKey) { - setPendingProfile(profile); + setPendingProfile({ ...profile, settings: account.settings }); openLoaderModal(); core.transport.dispatch({ action: 'Ctx', @@ -350,7 +351,7 @@ const Intro = ({ queryParams }) => { }); setShowProfiles(false); } - }, [core, openLoaderModal]); + }, [core, openLoaderModal, pendingProfile, dispatch]); const onAddProfile = React.useCallback(() => { setShowProfiles(false); }, []);