mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-01-11 22:40:31 +00:00
feat: add multi-account switching support
Implements functionality to switch between multiple Stremio accounts stored locally without logging out. Key changes: - Added AccountManager to handle localStorage persistence, token management, and settings. - Added ProfilePicker component with UI for selecting, managing, and deleting profiles. - Integrated "Change Profile" and "Log out" actions in the NavMenu. - Improved accessibility (aria-labels, semantic buttons) and UI consistency (CSS variables). - Fixed race conditions in authentication flow and improved hook dependencies. - Added graceful handling of token expiration.
This commit is contained in:
parent
da675cd56c
commit
482f2e77f7
8 changed files with 628 additions and 24 deletions
|
|
@ -15,7 +15,9 @@ const withProtectedRoutes = (Component) => {
|
||||||
previousAuthRef.current = profile.auth;
|
previousAuthRef.current = profile.auth;
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
const onRouteChange = React.useCallback((routeConfig) => {
|
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('#/');
|
window.location.replace('#/');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/common/AccountManager.js
Normal file
86
src/common/AccountManager.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Copyright (C) 2017-2026 Smart code 203358507
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...filtered, newAccount]));
|
||||||
|
} catch (_e) {
|
||||||
|
console.error('Failed to add account', _e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: (email, settings) => {
|
||||||
|
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) {
|
||||||
|
console.error('Failed to update settings', _e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSettings: (email) => {
|
||||||
|
const accounts = AccountManager.getAccounts();
|
||||||
|
const account = accounts.find((a) => a.email === email);
|
||||||
|
return account?.settings || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAccount: (email) => {
|
||||||
|
try {
|
||||||
|
const accounts = AccountManager.getAccounts();
|
||||||
|
const filtered = accounts.filter((a) => a.email !== email);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
} catch (_e) {
|
||||||
|
console.error('Failed to remove account', _e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
switchTo: (email) => {
|
||||||
|
const accounts = AccountManager.getAccounts();
|
||||||
|
const target = accounts.find((a) => a.email === email);
|
||||||
|
if (target && target.authKey) {
|
||||||
|
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) {
|
||||||
|
console.error('Failed to switch account', _e);
|
||||||
|
}
|
||||||
|
return updatedTarget;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = AccountManager;
|
||||||
|
|
@ -8,6 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||||
const { useServices } = require('stremio/services');
|
const { useServices } = require('stremio/services');
|
||||||
const { Button } = require('stremio/components');
|
const { Button } = require('stremio/components');
|
||||||
const { default: useFullscreen } = require('stremio/common/useFullscreen');
|
const { default: useFullscreen } = require('stremio/common/useFullscreen');
|
||||||
|
const AccountManager = require('stremio/common/AccountManager');
|
||||||
const useProfile = require('stremio/common/useProfile');
|
const useProfile = require('stremio/common/useProfile');
|
||||||
const usePWA = require('stremio/common/usePWA');
|
const usePWA = require('stremio/common/usePWA');
|
||||||
const useTorrent = require('stremio/common/useTorrent');
|
const useTorrent = require('stremio/common/useTorrent');
|
||||||
|
|
@ -29,14 +30,33 @@ const NavMenuContent = ({ onClick }) => {
|
||||||
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
|
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
|
||||||
);
|
);
|
||||||
}, [profile.settings, streamingServer.settings]);
|
}, [profile.settings, streamingServer.settings]);
|
||||||
|
const onChangeProfile = React.useCallback((event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
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 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({
|
core.transport.dispatch({
|
||||||
action: 'Ctx',
|
action: 'Ctx',
|
||||||
args: {
|
args: {
|
||||||
action: 'Logout'
|
action: 'Logout'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
if (remainingAccounts.length > 0) {
|
||||||
|
window.location.hash = '#/intro?profiles=true';
|
||||||
|
}
|
||||||
|
}, [profile.auth, core]);
|
||||||
const onPlayMagnetLinkClick = React.useCallback(async () => {
|
const onPlayMagnetLinkClick = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const clipboardText = await navigator.clipboard.readText();
|
const clipboardText = await navigator.clipboard.readText();
|
||||||
|
|
@ -64,9 +84,25 @@ const NavMenuContent = ({ onClick }) => {
|
||||||
<div className={styles['email-container']}>
|
<div className={styles['email-container']}>
|
||||||
<div className={styles['email-label']}>{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}</div>
|
<div className={styles['email-label']}>{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className={styles['logout-button-container']} title={profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')} href={profile.auth === null ? '#/intro' : null} onClick={profile.auth !== null ? logoutButtonOnClick : null}>
|
<div className={styles['user-actions-container']}>
|
||||||
<div className={styles['logout-label']}>{profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')}</div>
|
{
|
||||||
</Button>
|
profile.auth !== null ?
|
||||||
|
<Button className={styles['user-action-button']} title={t('CHANGE_PROFILE', { defaultValue: 'Change Profile' })} onClick={onChangeProfile}>
|
||||||
|
<div className={styles['user-action-label']}>{t('CHANGE_PROFILE', { defaultValue: 'Change Profile' })}</div>
|
||||||
|
</Button>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
profile.auth !== null ?
|
||||||
|
<div className={styles['user-actions-separator']}>/</div>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
<Button className={styles['user-action-button']} title={profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')} href={profile.auth === null ? '#/intro' : null} onClick={profile.auth !== null ? logoutButtonOnClick : null}>
|
||||||
|
<div className={styles['user-action-label']}>{profile.auth === null ? `${t('LOG_IN')} / ${t('SIGN_UP')}` : t('LOG_OUT')}</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,22 +61,35 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-button-container {
|
.user-actions-container {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
&:hover, &:focus {
|
.user-actions-separator {
|
||||||
outline: none;
|
color: var(--primary-foreground-color);
|
||||||
|
opacity: 0.5;
|
||||||
.logout-label {
|
font-size: 0.9rem;
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-label {
|
.user-action-button {
|
||||||
flex: 1;
|
flex: none;
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
&:hover, &:focus {
|
||||||
color: var(--primary-foreground-color);
|
outline: none;
|
||||||
|
|
||||||
|
.user-action-label {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-action-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
src/components/ProfilePicker/ProfilePicker.js
Normal file
152
src/components/ProfilePicker/ProfilePicker.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classnames(styles['profile-item'], { [styles['editing']]: isEditing })}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className={styles['avatar-wrapper']}>
|
||||||
|
<Image
|
||||||
|
className={styles['avatar']}
|
||||||
|
src={profile.avatar || require('/images/default_avatar.png')}
|
||||||
|
alt={profile.email}
|
||||||
|
/>
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
className={styles['delete-button']}
|
||||||
|
onClick={handleDelete}
|
||||||
|
title={t('DELETE_PROFILE', { defaultValue: 'Delete profile' })}
|
||||||
|
aria-label={t('DELETE_PROFILE', { defaultValue: 'Delete profile' })}
|
||||||
|
>
|
||||||
|
<Icon className={styles['delete-icon']} name={'close'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={styles['profile-name']}>{displayName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfileItem.propTypes = {
|
||||||
|
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, t }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles['profile-item']}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={t('ADD_PROFILE', { defaultValue: 'Add new profile' })}
|
||||||
|
>
|
||||||
|
<div className={classnames(styles['avatar-wrapper'], styles['add-profile-wrapper'])}>
|
||||||
|
<Icon className={styles['add-icon']} name={'add'} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AddProfileButton.propTypes = {
|
||||||
|
onClick: PropTypes.func.isRequired,
|
||||||
|
t: 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 (
|
||||||
|
<div className={styles['profile-picker-overlay']}>
|
||||||
|
<div className={styles['background-container']} />
|
||||||
|
<div className={styles['profile-picker-container']}>
|
||||||
|
<h1 className={styles['title']}>{title}</h1>
|
||||||
|
|
||||||
|
<div className={styles['profiles-grid']}>
|
||||||
|
{profiles.map((profile, index) => (
|
||||||
|
<ProfileItem
|
||||||
|
key={profile.email || index}
|
||||||
|
profile={profile}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onSelect={onSelectProfile}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!isEditing && <AddProfileButton onClick={onAddProfile} t={t} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={classnames(styles['manage-button'], { [styles['done-button']]: isEditing })}
|
||||||
|
onClick={toggleEditMode}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProfilePicker.propTypes = {
|
||||||
|
profiles: PropTypes.array,
|
||||||
|
onSelectProfile: PropTypes.func,
|
||||||
|
onAddProfile: PropTypes.func,
|
||||||
|
onDeleteProfile: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ProfilePicker;
|
||||||
232
src/components/ProfilePicker/styles.less
Normal file
232
src/components/ProfilePicker/styles.less
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
// Copyright (C) 2017-2023 Smart code 203358507
|
||||||
|
|
||||||
|
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||||
|
|
||||||
|
.profile-picker-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
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 {
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeIn 0.4s ease-in-out;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
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;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.avatar-wrapper {
|
||||||
|
border-color: var(--primary-foreground-color);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.profile-name {
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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: hidden;
|
||||||
|
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: var(--overlay-color);
|
||||||
|
border: 3px solid var(--secondary-foreground-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--primary-foreground-color);
|
||||||
|
border-color: var(--primary-foreground-color);
|
||||||
|
.add-icon {
|
||||||
|
fill: var(--primary-background-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--signal-error-color);
|
||||||
|
border: 2px solid var(--primary-background-color);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: var(--primary-foreground-color);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-icon {
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
fill: var(--primary-foreground-color);
|
||||||
|
transition: fill 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
max-width: 12vw;
|
||||||
|
min-width: 100px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-button {
|
||||||
|
margin-top: 3rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--primary-foreground-color);
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
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;
|
||||||
|
border-radius: 3.5rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done-button {
|
||||||
|
background-color: var(--primary-accent-color);
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
border-color: var(--primary-accent-color);
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: scale(1.1); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
@ -30,8 +30,10 @@ import TextInput from './TextInput';
|
||||||
import Toggle from './Toggle';
|
import Toggle from './Toggle';
|
||||||
import Transition from './Transition';
|
import Transition from './Transition';
|
||||||
import Video from './Video';
|
import Video from './Video';
|
||||||
|
import ProfilePicker from './ProfilePicker/ProfilePicker';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
ProfilePicker,
|
||||||
AddonDetailsModal,
|
AddonDetailsModal,
|
||||||
BottomSheet,
|
BottomSheet,
|
||||||
Button,
|
Button,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||||
const { Modal, useRouteFocused } = require('stremio-router');
|
const { Modal, useRouteFocused } = require('stremio-router');
|
||||||
const { useServices } = require('stremio/services');
|
const { useServices } = require('stremio/services');
|
||||||
const { useBinaryState } = require('stremio/common');
|
const { useBinaryState } = require('stremio/common');
|
||||||
const { Button, Image, Checkbox } = require('stremio/components');
|
const { Button, Image, Checkbox, ProfilePicker } = require('stremio/components');
|
||||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||||
const PasswordResetModal = require('./PasswordResetModal');
|
const PasswordResetModal = require('./PasswordResetModal');
|
||||||
const useFacebookLogin = require('./useFacebookLogin');
|
const useFacebookLogin = require('./useFacebookLogin');
|
||||||
const { default: useAppleLogin } = require('./useAppleLogin');
|
const { default: useAppleLogin } = require('./useAppleLogin');
|
||||||
|
const AccountManager = require('stremio/common/AccountManager');
|
||||||
|
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
|
|
@ -249,8 +250,10 @@ const Intro = ({ queryParams }) => {
|
||||||
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
|
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
|
||||||
}, []);
|
}, []);
|
||||||
const switchFormOnClick = React.useCallback(() => {
|
const switchFormOnClick = React.useCallback(() => {
|
||||||
const queryParams = new URLSearchParams([['form', state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM]]);
|
const newForm = state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM;
|
||||||
window.location = `#/intro?${queryParams.toString()}`;
|
const currentParams = new URLSearchParams(window.location.hash.split('?')[1]);
|
||||||
|
currentParams.set('form', newForm);
|
||||||
|
window.location = `#/intro?${currentParams.toString()}`;
|
||||||
}, [state.form]);
|
}, [state.form]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
|
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
|
||||||
|
|
@ -262,15 +265,32 @@ const Intro = ({ queryParams }) => {
|
||||||
errorRef.current.scrollIntoView();
|
errorRef.current.scrollIntoView();
|
||||||
}
|
}
|
||||||
}, [state.error]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (routeFocused) {
|
if (routeFocused && !showProfiles && emailRef.current) {
|
||||||
emailRef.current.focus();
|
emailRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [state.form, routeFocused]);
|
}, [state.form, routeFocused, showProfiles]);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onCoreEvent = ({ event, args }) => {
|
const onCoreEvent = ({ event, args }) => {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'UserAuthenticated': {
|
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();
|
closeLoaderModal();
|
||||||
if (routeFocused) {
|
if (routeFocused) {
|
||||||
window.location = '#/';
|
window.location = '#/';
|
||||||
|
|
@ -280,8 +300,23 @@ const Intro = ({ queryParams }) => {
|
||||||
case 'Error': {
|
case 'Error': {
|
||||||
if (args.source.event === 'UserAuthenticated') {
|
if (args.source.event === 'UserAuthenticated') {
|
||||||
closeLoaderModal();
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +325,53 @@ const Intro = ({ queryParams }) => {
|
||||||
return () => {
|
return () => {
|
||||||
core.transport.off('CoreEvent', onCoreEvent);
|
core.transport.off('CoreEvent', onCoreEvent);
|
||||||
};
|
};
|
||||||
}, [routeFocused]);
|
}, [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, settings: account.settings });
|
||||||
|
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, pendingProfile, dispatch]);
|
||||||
|
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 (
|
||||||
|
<ProfilePicker
|
||||||
|
profiles={savedAccounts}
|
||||||
|
onSelectProfile={onSelectProfile}
|
||||||
|
onAddProfile={onAddProfile}
|
||||||
|
onDeleteProfile={onDeleteProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles['intro-container']}>
|
<div className={styles['intro-container']}>
|
||||||
<div className={styles['background-container']} />
|
<div className={styles['background-container']} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue