Compare commits

...

2 commits

Author SHA1 Message Date
Maxime Deprince
74153b0f2c
Merge 0d33ccd271 into da675cd56c 2026-01-11 11:18:55 +00:00
Linkrédible
0d33ccd271 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
2026-01-11 12:19:24 +01:00
5 changed files with 176 additions and 66 deletions

View file

@ -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;
}

View file

@ -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();

View file

@ -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>

View file

@ -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;
}
}
}

View file

@ -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);
}, []);