mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-01-11 22:40:31 +00:00
Compare commits
2 commits
a95efdff0b
...
74153b0f2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74153b0f2c | ||
|
|
0d33ccd271 |
5 changed files with 176 additions and 66 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => {
|
|||
}, [onDelete, profile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={classnames(styles['profile-item'], { [styles['editing']]: isEditing })}
|
||||
onClick={handleClick}
|
||||
>
|
||||
|
|
@ -48,26 +49,33 @@ const ProfileItem = ({ profile, isEditing, onSelect, onDelete }) => {
|
|||
)}
|
||||
</div>
|
||||
<span className={styles['profile-name']}>{displayName}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={styles['profile-item']} onClick={onClick}>
|
||||
<button type="button" className={styles['profile-item']} onClick={onClick}>
|
||||
<div className={classnames(styles['avatar-wrapper'], styles['add-profile-wrapper'])}>
|
||||
<Icon className={styles['add-icon']} name={'plus'} />
|
||||
<Icon className={styles['add-icon']} name={'add'} />
|
||||
</div>
|
||||
<span className={styles['profile-name']}>{t('ADD', { defaultValue: 'Add' })}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -99,6 +107,7 @@ const ProfilePicker = ({ profiles = [], onSelectProfile, onAddProfile, onDeleteP
|
|||
|
||||
return (
|
||||
<div className={styles['profile-picker-overlay']}>
|
||||
<div className={styles['background-container']} />
|
||||
<div className={styles['profile-picker-container']}>
|
||||
<h1 className={styles['title']}>{title}</h1>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
Loading…
Reference in a new issue