Merge pull request #250 from simoleo89/feat/toolbar-habbo

Feat/toolbar habbo
This commit is contained in:
DuckieTM
2026-06-17 10:06:25 +02:00
committed by GitHub
16 changed files with 525 additions and 911 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -79,11 +79,17 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
// below uses it, so a stale `indexOffset` (after the list shrinks or the fit // below uses it, so a stale `indexOffset` (after the list shrinks or the fit
// grows) renders correctly and self-corrects on the next arrow click — no // grows) renders correctly and self-corrects on the next arrow click — no
// write-back effect needed. // write-back effect needed.
const maxOffset = Math.max(0, (onlineFriends.length - maxVisible)); // Defensive: never let a null/undefined slip into the friend map. The
// legacy bar padded empty slots with `null` and rendered each as a
// FriendBarItemView (which falls back to the "find friends" chip), so an
// empty list produced THREE "Trova Amici" buttons. Filtering here makes the
// search chip below the ONLY source of that affordance — exactly one, always.
const validFriends = onlineFriends.filter(Boolean);
const maxOffset = Math.max(0, (validFriends.length - maxVisible));
const safeOffset = Math.min(indexOffset, maxOffset); const safeOffset = Math.min(indexOffset, maxOffset);
const canScrollLeft = (safeOffset > 0); const canScrollLeft = (safeOffset > 0);
const canScrollRight = (safeOffset < maxOffset); const canScrollRight = (safeOffset < maxOffset);
const visibleFriends = onlineFriends.slice(safeOffset, (safeOffset + maxVisible)); const visibleFriends = validFriends.slice(safeOffset, (safeOffset + maxVisible));
return ( return (
<motion.div <motion.div
@@ -135,7 +141,7 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
> >
<FriendBarItemView friend={ null } /> <FriendBarItemView friend={ null } />
</motion.div> </motion.div>
{ (!onlineFriends.length && (requestsCount <= 0)) && { (!validFriends.length && (requestsCount <= 0)) &&
<motion.div <motion.div
key="friend-empty" key="friend-empty"
variants={ itemVariants } variants={ itemVariants }
+11 -9
View File
@@ -96,17 +96,19 @@ export const HelpView: FC<{}> = props =>
return ( return (
<> <>
{ isVisible && { isVisible &&
<NitroCardView className="nitro-help" theme="primary-slim"> <NitroCardView className={ `nitro-help${ activeReport ? '' : ' w-[420px]' }` } theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('help.button.cfh') } onCloseClick={ onClose } /> <NitroCardHeaderView headerText={ LocalizeText('help.button.cfh') } onCloseClick={ onClose } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black">
<Grid> { activeReport
<Column center overflow="hidden" size={ 5 }> ? <Grid>
<div className="index-image" /> <Column center overflow="hidden" size={ 5 }>
</Column> <div className="index-image" />
<Column justifyContent="between" overflow="hidden" size={ 7 }> </Column>
<CurrentStepView /> <Column justifyContent="between" overflow="hidden" size={ 7 }>
</Column> <CurrentStepView />
</Grid> </Column>
</Grid>
: <CurrentStepView /> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> } </NitroCardView> }
<SanctionSatusView /> <SanctionSatusView />
+27 -12
View File
@@ -1,9 +1,11 @@
import { GetCfhStatusMessageComposer } from '@nitrots/nitro-renderer'; import { GetCfhStatusMessageComposer } from '@nitrots/nitro-renderer';
import { FC } from 'react'; import { FC } from 'react';
import { DispatchUiEvent, GetConfigurationValue, LocalizeText, ReportState, ReportType, SendMessageComposer } from '../../../api'; import { FaArrowCircleRight } from 'react-icons/fa';
import { Button, Text } from '../../../common'; import { CreateLinkEvent, DispatchUiEvent, GetConfigurationValue, LocalizeText, ReportState, ReportType, SendMessageComposer } from '../../../api';
import { Text } from '../../../common';
import { GuideToolEvent } from '../../../events'; import { GuideToolEvent } from '../../../events';
import { useHelp } from '../../../hooks'; import { useHelp } from '../../../hooks';
import helpDuck from '../../../assets/images/help/help-duck.png';
export const HelpIndexView: FC<{}> = props => export const HelpIndexView: FC<{}> = props =>
{ {
@@ -21,17 +23,30 @@ export const HelpIndexView: FC<{}> = props =>
}; };
return ( return (
<> <div className="flex flex-col gap-2 py-1">
<div className="flex flex-col justify-center alignp-items-enter grow! gap-1"> <Text bold fontSize={ 3 }>{ LocalizeText('help.main.frame.title') }</Text>
<Text fontSize={ 3 }>{ LocalizeText('help.main.frame.title') }</Text> <Text center className="text-[#5c5c5c]">{ LocalizeText('help.main.frame.description') }</Text>
<Text>{ LocalizeText('help.main.self.description') }</Text> <div className="flex justify-center py-1">
<img src={ helpDuck } alt="" className="h-[105px] w-auto [image-rendering:pixelated]" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1.5">
<Button onClick={ onReportClick }>{ LocalizeText('help.main.bully.subtitle') }</Button> <button type="button" className="habbo-btn-green" onClick={ onReportClick }>{ LocalizeText('help.main.bully.subtitle') }</button>
<Button disabled={ !GetConfigurationValue('guides.enabled') } onClick={ () => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.CREATE_HELP_REQUEST)) }>{ LocalizeText('help.main.help.title') }</Button> <button type="button" className="habbo-btn-green" disabled={ !GetConfigurationValue('guides.enabled') } onClick={ () => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.CREATE_HELP_REQUEST)) }>{ LocalizeText('help.main.help.title') }</button>
<Button disabled={ true }>{ LocalizeText('help.main.self.tips.title') }</Button>
</div> </div>
<Button textColor="black" variant="link" onClick={ () => SendMessageComposer(new GetCfhStatusMessageComposer(false)) }>{ LocalizeText('help.main.my.sanction.status') }</Button> <div className="flex flex-col gap-1 pt-1">
</> <button type="button" className="help-link" onClick={ () => CreateLinkEvent('habbopages/help') }>
<FaArrowCircleRight className="help-link__icon" />
{ LocalizeText('help.main.faq.link.text') }
</button>
<button type="button" className="help-link" onClick={ () => SendMessageComposer(new GetCfhStatusMessageComposer(false)) }>
<FaArrowCircleRight className="help-link__icon" />
{ LocalizeText('help.main.my.sanction.status') }
</button>
<button type="button" className="help-link" onClick={ () => SendMessageComposer(new GetCfhStatusMessageComposer(true)) }>
<FaArrowCircleRight className="help-link__icon" />
{ LocalizeText('help.main.my.reports.status') }
</button>
</div>
</div>
); );
}; };
@@ -1,6 +1,7 @@
import { FC } from 'react'; import { FC } from 'react';
import { LocalizeText } from '../../../api'; import { FaArrowCircleRight } from 'react-icons/fa';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common'; import { CreateLinkEvent, LocalizeText } from '../../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../common';
import { useHelp } from '../../../hooks'; import { useHelp } from '../../../hooks';
export const SanctionSatusView: FC<{}> = props => export const SanctionSatusView: FC<{}> = props =>
@@ -37,38 +38,45 @@ export const SanctionSatusView: FC<{}> = props =>
if(!sanctionInfo) return null; if(!sanctionInfo) return null;
return ( return (
<NitroCardView className="nitro-help" theme="primary-slim"> <NitroCardView className="nitro-help w-[420px]" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('help.sanction.info.title') } onCloseClick={ () => setSanctionInfo(null) } /> <NitroCardHeaderView headerText={ LocalizeText('help.sanction.info.title') } onCloseClick={ () => setSanctionInfo(null) } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black">
<Grid> <div className="flex min-h-[170px] flex-col">
<Column center overflow="hidden" size={ 5 }> <div className="flex flex-col gap-1">
<div className="index-image" />
</Column>
<Column justifyContent="between" overflow="hidden" size={ 7 }>
{ (sanctionInfo.sanctionReason === 'cfh.reason.EMPTY') { (sanctionInfo.sanctionReason === 'cfh.reason.EMPTY')
? <div className="col-span-12 font-bold ">{ LocalizeText('help.sanction.current.none') }</div> ? <div className="font-bold">{ LocalizeText('help.sanction.current.none') }</div>
: <> : <>
{ ((sanctionInfo.probationHoursLeft > 0) || (sanctionInfo.isSanctionActive)) && { ((sanctionInfo.probationHoursLeft > 0) || (sanctionInfo.isSanctionActive)) &&
<div className="col-span-12 font-bold ">{ LocalizeText('help.sanction.probation.reminder') }</div> <div className="font-bold">{ LocalizeText('help.sanction.probation.reminder') }</div>
} }
<div className={ `col-span-12 font-bold ${ sanctionInfo.isSanctionNew ? 'text-danger' : '' }` }> <div className={ `font-bold ${ sanctionInfo.isSanctionNew ? 'text-danger' : '' }` }>
{ LocalizeText('help.sanction.last.sanction') } { sanctionLocalization('current', sanctionInfo.sanctionName, sanctionInfo.sanctionLengthHours) } { LocalizeText('help.sanction.last.sanction') } { sanctionLocalization('current', sanctionInfo.sanctionName, sanctionInfo.sanctionLengthHours) }
</div> </div>
<div className="col-span-12">{ LocalizeText('generic.start.time') } { sanctionInfo.sanctionCreationTime }</div> <div>{ LocalizeText('generic.start.time') } { sanctionInfo.sanctionCreationTime }</div>
<div className="col-span-12">{ LocalizeText('generic.reason') } { sanctionInfo.sanctionReason }</div> <div>{ LocalizeText('generic.reason') } { sanctionInfo.sanctionReason }</div>
<div className="col-span-12">{ LocalizeText('help.sanction.probation.days.left') } { Math.trunc((sanctionInfo.probationHoursLeft / 24)) + 1 }</div> <div>{ LocalizeText('help.sanction.probation.days.left') } { Math.trunc((sanctionInfo.probationHoursLeft / 24)) + 1 }</div>
</> </>
} }
{ ((sanctionInfo.hasCustomMute) && (!(sanctionInfo.isSanctionActive))) && { ((sanctionInfo.hasCustomMute) && (!(sanctionInfo.isSanctionActive))) &&
<div className="col-span-12 font-bold ">{ LocalizeText('help.sanction.custom.mute') }</div> <div className="font-bold">{ LocalizeText('help.sanction.custom.mute') }</div>
} }
{ (sanctionInfo.tradeLockExpiryTime && sanctionInfo.tradeLockExpiryTime.length > 0) && { (sanctionInfo.tradeLockExpiryTime && sanctionInfo.tradeLockExpiryTime.length > 0) &&
<div className="col-span-12 font-bold ">{ LocalizeText('trade.locked.until') } { sanctionInfo.tradeLockExpiryTime }</div> <div className="font-bold">{ LocalizeText('trade.locked.until') } { sanctionInfo.tradeLockExpiryTime }</div>
} }
<div className="col-span-12">{ sanctionLocalization('next', sanctionInfo.nextSanctionName, sanctionInfo.nextSanctionLengthHours) }</div> { (sanctionInfo.sanctionReason !== 'cfh.reason.EMPTY') &&
<Button variant="success" onClick={ event => setSanctionInfo(null) }>{ LocalizeText('habbo.way.ok.button') }</Button> <div>{ sanctionLocalization('next', sanctionInfo.nextSanctionName, sanctionInfo.nextSanctionLengthHours) }</div>
</Column> }
</Grid> </div>
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
<button type="button" className="help-link" onClick={ () => CreateLinkEvent('habbopages/help') }>
<FaArrowCircleRight className="help-link__icon" />
{ LocalizeText('help.main.faq.link.text') }
</button>
<button type="button" className="habbo-btn-green habbo-btn-green--auto" onClick={ () => setSanctionInfo(null) }>
{ LocalizeText('habbo.way.ok.button') }
</button>
</div>
</div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
-165
View File
@@ -1,165 +0,0 @@
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
import { ClearRememberLogin, FriendlyTime, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { usePurse } from '../../hooks';
import purseIcon from '../../assets/images/rightside/purse.gif';
import { CurrencyView } from './views/CurrencyView';
import { SeasonalView } from './views/SeasonalView';
export const PurseClassicView: FC<{}> = props =>
{
const { purse = null, hcDisabled = false } = usePurse();
const [ isOpen, setIsOpen ] = useState(true);
const [ isCompact, setIsCompact ] = useState(false);
const displayedCurrencies = useMemo(() => GetConfigurationValue<number[]>('system.currency.types', []), []);
const currencyDisplayNumberShort = useMemo(() => GetConfigurationValue<boolean>('currency.display.number.short', false), []);
const getClubText = (() =>
{
if (!purse) return null;
const totalDays = ((purse.clubPeriods * 31) + purse.clubDays);
const minutesUntilExpiration = purse.minutesUntilExpiration;
if (purse.clubLevel === HabboClubLevelEnum.NO_CLUB) return LocalizeText('purse.clubdays.zero.amount.text');
else if ((minutesUntilExpiration > -1) && (minutesUntilExpiration < (60 * 24))) return FriendlyTime.shortFormat(minutesUntilExpiration * 60);
else return FriendlyTime.shortFormat(totalDays * 86400);
})();
const currencyTypes = useMemo(() =>
{
if (!purse || !purse.activityPoints || !purse.activityPoints.size) return [];
const types = Array.from(purse.activityPoints.keys()).filter(type => (displayedCurrencies.indexOf(type) >= 0));
types.sort((a, b) =>
{
if (a === 0) return -1;
if (b === 0) return 1;
if (a === 5) return -1;
if (b === 5) return 1;
return a - b;
});
return types;
}, [ displayedCurrencies, purse ]);
const primaryCurrencies = currencyTypes.slice(0, 2);
const seasonalCurrencies = currencyTypes.slice(2);
useEffect(() =>
{
if(isOpen)
{
setIsCompact(false);
return;
}
const timeout = window.setTimeout(() => setIsCompact(true), 220);
return () => window.clearTimeout(timeout);
}, [ isOpen ]);
const handleLogout = useCallback(async (event: React.MouseEvent) =>
{
event.stopPropagation();
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
const rememberToken = GetRememberLogin()?.token || '';
try
{
await fetch(logoutUrl, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroPurseLogout'
},
body: JSON.stringify({ ssoTicket, rememberToken })
});
}
catch
{ /* best-effort — proceed with local logout regardless */ }
ClearRememberLogin();
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);
if (!purse) return null;
return (
<Column alignItems="end" className="nitro-purse-container" gap={ 0 }>
<div className="nitro-purse-classic">
<div className={ `nitro-purse-shell ${ isCompact ? 'is-closed' : '' }` }>
<div className={ `nitro-purse ${ isCompact ? 'is-closed' : '' }` }>
<div className={ `nitro-purse__header ${ isCompact ? 'is-closed' : '' }` } onClick={ () => setIsOpen(value => !value) }>
<Flex alignItems="center" gap={ 1 } className={ isCompact ? 'nitro-purse__header-main is-closed' : 'nitro-purse__header-main' }>
<div className="nitro-purse__header-icon">
<img src={ purseIcon } alt="" className="nitro-purse__header-image" />
</div>
</Flex>
<div className={ `nitro-purse__header-toggle ${ isOpen ? 'is-open' : '' }` }>
<FaChevronDown className="fa-icon text-[10px]" />
</div>
</div>
<div className={ `nitro-purse__content ${ isOpen ? 'is-open' : 'is-closed' }` }>
<div className={ `nitro-purse__summary nitro-purse__summary--compact ${ hcDisabled ? 'is-no-hc' : '' }` }>
<div className="nitro-purse__primary">
<CurrencyView type={ -1 } amount={ purse.credits } short={ currencyDisplayNumberShort } />
{ primaryCurrencies.map(type => <CurrencyView key={ type } type={ type } amount={ purse.activityPoints.get(type) || 0 } short={ currencyDisplayNumberShort } />) }
</div>
{ !hcDisabled &&
<div className="nitro-purse-subscription" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('catalog/open/' + GetConfigurationValue<Record<string, string>>('catalog.links')?.['hc.buy_hc']);
} }>
<div className="nitro-purse-subscription__icon">
<LayoutCurrencyIcon type="hc" />
</div>
<div className="nitro-purse-subscription__copy">
<Text variant="white" className="nitro-purse-subscription__label">HC</Text>
<Text variant="white" className="nitro-purse-subscription__value">{ getClubText }</Text>
</div>
</div> }
<div className="nitro-purse__actions">
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--translate" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('translation-settings/toggle');
} } title="Google Translate">
<FaLanguage />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--help" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('help/show');
} } title={ LocalizeText('help.button.name') }>
<FaQuestionCircle />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('user-settings/toggle');
} } title={ LocalizeText('widget.memenu.settings.title') }>
<i className="nitro-icon icon-cog" />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--logout" onClick={ handleLogout } title="Log out">
<FaSignOutAlt />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{ seasonalCurrencies.length > 0 &&
<div className="nitro-purse__other nitro-purse__other--classic">
{ seasonalCurrencies.map(type => <SeasonalView key={ type } type={ type } amount={ purse.activityPoints.get(type) || 0 } />) }
</div> }
</Column>
);
};
-139
View File
@@ -1,139 +0,0 @@
import { CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo } from 'react';
import { FaChartBar, FaCog, FaSignOutAlt } from 'react-icons/fa';
import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api';
import { Column, LayoutCurrencyIcon } from '../../common';
import { usePurse } from '../../hooks';
import { CurrencyView } from './views/CurrencyView';
import { SeasonalView } from './views/SeasonalView';
const localizeWithFallback = (key: string, fallback: string) =>
{
const text = LocalizeText(key);
return (text && text !== key) ? text : fallback;
};
export const PurseModernView: FC<{}> = props =>
{
const { purse = null, hcDisabled = false } = usePurse();
const displayedCurrencies = useMemo(() => GetConfigurationValue<number[]>('system.currency.types', []), []);
const currencyDisplayNumberShort = useMemo(() => GetConfigurationValue<boolean>('currency.display.number.short', false), []);
const currencyTypes = useMemo(() =>
{
if (!purse || !purse.activityPoints || !purse.activityPoints.size) return [];
const types = Array.from(purse.activityPoints.keys()).filter(type => (displayedCurrencies.indexOf(type) >= 0));
types.sort((a, b) =>
{
if (a === 0) return -1;
if (b === 0) return 1;
if (a === 5) return -1;
if (b === 5) return 1;
return a - b;
});
return types;
}, [ displayedCurrencies, purse ]);
const hasDiamonds = currencyTypes.indexOf(5) >= 0;
const hasDuckets = currencyTypes.indexOf(0) >= 0;
const otherCurrencies = currencyTypes.filter(type => (type !== 0 && type !== 5));
const joinLabel = useMemo(() => localizeWithFallback('purse.join', 'Join'), []);
const earningsLabel = useMemo(() => localizeWithFallback('earnings.title', 'Earnings'), []);
const helpLabel = useMemo(() => localizeWithFallback('help.button.name', 'Help'), []);
const openClub = useCallback((event: React.MouseEvent) =>
{
event.stopPropagation();
const page = GetConfigurationValue<string>('hc.buy_hc', 'habbo_club');
CreateLinkEvent('catalog/open/' + page);
}, []);
const openEarnings = useCallback((event: React.MouseEvent) =>
{
event.stopPropagation();
CreateLinkEvent('habboUI/open/vault');
}, []);
const handleLogout = useCallback(async (event: React.MouseEvent) =>
{
event.stopPropagation();
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
const rememberToken = GetRememberLogin()?.token || '';
try
{
await fetch(logoutUrl, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroPurseLogout'
},
body: JSON.stringify({ ssoTicket, rememberToken })
});
}
catch
{ /* best-effort — proceed with local logout regardless */ }
ClearRememberLogin();
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);
if (!purse) return null;
return (
<Column alignItems="end" className="nitro-purse-container" gap={ 0 }>
<div className="nitro-purse">
<div className="nitro-purse__body">
<div className="nitro-purse__currencies">
{ hasDiamonds && <CurrencyView type={ 5 } amount={ purse.activityPoints.get(5) || 0 } short={ currencyDisplayNumberShort } /> }
<CurrencyView type={ -1 } amount={ purse.credits } short={ currencyDisplayNumberShort } />
{ hasDuckets && <CurrencyView type={ 0 } amount={ purse.activityPoints.get(0) || 0 } short={ currencyDisplayNumberShort } /> }
</div>
<div className="nitro-purse__col nitro-purse__col--primary">
{ !hcDisabled &&
<button type="button" className="nitro-purse__btn nitro-purse__btn--join" onClick={ openClub } title={ joinLabel }>
<LayoutCurrencyIcon type="hc" />
<span>{ joinLabel }</span>
</button> }
<button type="button" className="nitro-purse__btn nitro-purse__btn--earnings" onClick={ openEarnings } title={ earningsLabel }>
<FaChartBar className="nitro-purse__btn-icon" />
<span>{ earningsLabel }</span>
</button>
</div>
<div className="nitro-purse__col nitro-purse__col--actions">
<button type="button" className="nitro-purse__btn nitro-purse__btn--help" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('help/show');
} } title={ helpLabel }>
<span>{ helpLabel }</span>
</button>
<button type="button" className="nitro-purse__btn nitro-purse__btn--icon nitro-purse__btn--logout" onClick={ handleLogout } title="Log out">
<FaSignOutAlt />
</button>
<button type="button" className="nitro-purse__btn nitro-purse__btn--icon nitro-purse__btn--settings" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('user-settings/toggle');
} } title={ LocalizeText('widget.memenu.settings.title') }>
<FaCog />
</button>
</div>
</div>
</div>
{ otherCurrencies.length > 0 &&
<div className="nitro-purse__other">
{ otherCurrencies.map(type => <SeasonalView key={ type } type={ type } amount={ purse.activityPoints.get(type) || 0 } />) }
</div> }
</Column>
);
};
+149 -3
View File
@@ -1,7 +1,153 @@
import { FC } from 'react'; import { CreateLinkEvent } from '@nitrots/nitro-renderer';
import { PurseModernView } from './PurseModernView'; import { FC, useCallback, useMemo, useState } from 'react';
import { FaChartBar, FaCog, FaSignOutAlt } from 'react-icons/fa';
import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api';
import { Column, LayoutCurrencyIcon } from '../../common';
import { usePurse } from '../../hooks';
import { CurrencyView } from './views/CurrencyView';
import { SeasonalView } from './views/SeasonalView';
const localizeWithFallback = (key: string, fallback: string) =>
{
const text = LocalizeText(key);
return (text && text !== key) ? text : fallback;
};
export const PurseView: FC<{}> = props => export const PurseView: FC<{}> = props =>
{ {
return <PurseModernView />; const { purse = null, hcDisabled = false } = usePurse();
const [ settingsMenuOpen, setSettingsMenuOpen ] = useState(false);
const openSettingsSection = useCallback((section: string) =>
{
CreateLinkEvent('user-settings/show/' + section);
setSettingsMenuOpen(false);
}, []);
const displayedCurrencies = useMemo(() => GetConfigurationValue<number[]>('system.currency.types', []), []);
const currencyDisplayNumberShort = useMemo(() => GetConfigurationValue<boolean>('currency.display.number.short', false), []);
const currencyTypes = useMemo(() =>
{
if (!purse || !purse.activityPoints || !purse.activityPoints.size) return [];
const types = Array.from(purse.activityPoints.keys()).filter(type => (displayedCurrencies.indexOf(type) >= 0));
types.sort((a, b) =>
{
if (a === 0) return -1;
if (b === 0) return 1;
if (a === 5) return -1;
if (b === 5) return 1;
return a - b;
});
return types;
}, [ displayedCurrencies, purse ]);
const hasDiamonds = currencyTypes.indexOf(5) >= 0;
const hasDuckets = currencyTypes.indexOf(0) >= 0;
const otherCurrencies = currencyTypes.filter(type => (type !== 0 && type !== 5));
const joinLabel = useMemo(() => localizeWithFallback('purse.join', 'Join'), []);
const earningsLabel = useMemo(() => localizeWithFallback('earnings.title', 'Earnings'), []);
const helpLabel = useMemo(() => localizeWithFallback('help.button.name', 'Help'), []);
const openClub = useCallback((event: React.MouseEvent) =>
{
event.stopPropagation();
CreateLinkEvent('habboUI/open/hccenter');
}, []);
const openEarnings = useCallback((event: React.MouseEvent) =>
{
event.stopPropagation();
CreateLinkEvent('habboUI/open/vault');
}, []);
const handleLogout = useCallback(async (event: React.MouseEvent) =>
{
event.stopPropagation();
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
const rememberToken = GetRememberLogin()?.token || '';
try
{
await fetch(logoutUrl, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroPurseLogout'
},
body: JSON.stringify({ ssoTicket, rememberToken })
});
}
catch
{ /* best-effort — proceed with local logout regardless */ }
ClearRememberLogin();
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);
if (!purse) return null;
return (
<Column alignItems="end" className="nitro-purse-container" gap={ 0 }>
<div className="nitro-purse">
<div className="nitro-purse__body">
<div className="nitro-purse__currencies">
{ hasDiamonds && <CurrencyView type={ 5 } amount={ purse.activityPoints.get(5) || 0 } short={ currencyDisplayNumberShort } /> }
<CurrencyView type={ -1 } amount={ purse.credits } short={ currencyDisplayNumberShort } />
{ hasDuckets && <CurrencyView type={ 0 } amount={ purse.activityPoints.get(0) || 0 } short={ currencyDisplayNumberShort } /> }
</div>
<div className="nitro-purse__col nitro-purse__col--primary">
{ !hcDisabled &&
<button type="button" className="nitro-purse__btn nitro-purse__btn--join" onClick={ openClub } title={ joinLabel }>
<LayoutCurrencyIcon type="hc" />
<span>{ joinLabel }</span>
</button> }
<button type="button" className="nitro-purse__btn nitro-purse__btn--earnings" onClick={ openEarnings } title={ earningsLabel }>
<FaChartBar className="nitro-purse__btn-icon" />
<span>{ earningsLabel }</span>
</button>
</div>
<div className="nitro-purse__col nitro-purse__col--actions">
<button type="button" className="nitro-purse__btn nitro-purse__btn--help" onClick={ event =>
{
event.stopPropagation(); CreateLinkEvent('help/show');
} } title={ helpLabel }>
<span>{ helpLabel }</span>
</button>
<button type="button" className="nitro-purse__btn nitro-purse__btn--icon nitro-purse__btn--logout" onClick={ handleLogout } title="Log out">
<FaSignOutAlt />
</button>
<button type="button" className="nitro-purse__btn nitro-purse__btn--icon nitro-purse__btn--settings" onClick={ event =>
{
event.stopPropagation(); setSettingsMenuOpen(value => !value);
} } title={ LocalizeText('widget.memenu.settings.title') }>
<FaCog />
</button>
</div>
</div>
</div>
{ settingsMenuOpen &&
<div className="nitro-purse-menu">
<button type="button" className="nitro-purse-menu__item" onClick={ () => openSettingsSection('audio') }>Impostazioni Audio</button>
<button type="button" className="nitro-purse-menu__item nitro-purse-menu__item--disabled" disabled>Impostazioni Discord</button>
<button type="button" className="nitro-purse-menu__item" onClick={ () => openSettingsSection('chat') }>Impostazioni Chat</button>
<button type="button" className="nitro-purse-menu__item" onClick={ () => openSettingsSection('other') }>Altre Impostazioni</button>
<button type="button" className="nitro-purse-menu__item" onClick={ () => { CreateLinkEvent('user-account-settings/show'); setSettingsMenuOpen(false); } }>Gestione Account</button>
<button type="button" className="nitro-purse-menu__item nitro-purse-menu__item--disabled" disabled>Filtro Parole</button>
</div> }
{ otherCurrencies.length > 0 &&
<div className="nitro-purse__other">
{ otherCurrencies.map(type => <SeasonalView key={ type } type={ type } amount={ purse.activityPoints.get(type) || 0 } />) }
</div> }
</Column>
);
}; };
@@ -23,8 +23,11 @@ export const ChatInputStyleSelectorView: FC<ChatInputStyleSelectorViewProps> = p
return ( return (
<Popover.Root open={selectorVisible} onOpenChange={setSelectorVisible}> <Popover.Root open={selectorVisible} onOpenChange={setSelectorVisible}>
<Popover.Trigger asChild> <Popover.Trigger asChild>
<div className="chatstyles-anchor"> <div className="flex h-[26px] items-center gap-[3px] cursor-pointer select-none pl-[2px]" aria-label="Stili chat">
<div className="nitro-icon chatstyles-icon" /> <svg className="h-[9px] w-[9px] shrink-0 text-black/70" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 3 } d="M19 9l-7 7-7-7" />
</svg>
<div className="nitro-icon chatstyles-icon" style={ { filter: 'none' } } />
</div> </div>
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
@@ -313,7 +313,7 @@ export const ChatInputView: FC<{}> = props =>
return ( return (
createPortal( createPortal(
<div className="nitro-chat-input-container relative flex h-[38px] w-full items-center justify-between overflow-visible rounded-[12px] border-2 border-black bg-white pr-[8px]"> <div className="nitro-chat-input-container relative flex h-[38px] w-full items-center justify-between gap-[6px] overflow-visible rounded-[12px] border-2 border-black bg-[#dcdcdc] pl-[8px] pr-[8px]">
{ commandSelectorVisible && { commandSelectorVisible &&
<ChatInputCommandSelectorView <ChatInputCommandSelectorView
commands={ filteredCommands } commands={ filteredCommands }
@@ -331,14 +331,16 @@ export const ChatInputView: FC<{}> = props =>
onSelect={ mention.apply } onSelect={ mention.apply }
onHover={ mention.setSelectedIndex } onHover={ mention.setSelectedIndex }
/> } /> }
<div className="flex-1 items-center input-sizer">
{ !floodBlocked &&
<input ref={ inputRef } className="w-full border-none bg-transparent px-[10px] text-[0.86rem] text-black placeholder:text-[#6c757d] focus:border-current focus:shadow-none focus:ring-0" maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } /> }
{ floodBlocked &&
<Text variant="danger">{ LocalizeText('chat.input.alert.flood', [ 'time' ], [ floodBlockedSeconds.toString() ]) } </Text> }
</div>
<ChatInputEmojiSelectorView addChatEmoji={ addChatEmoji } />
<ChatInputStyleSelectorView chatStyleId={ chatStyleId } chatStyleIds={ chatStyleIds } selectChatStyleId={ updateChatStyleId } /> <ChatInputStyleSelectorView chatStyleId={ chatStyleId } chatStyleIds={ chatStyleIds } selectChatStyleId={ updateChatStyleId } />
{ !floodBlocked &&
<div className="flex-1 items-center input-sizer">
<input ref={ inputRef } className="w-full border-none bg-transparent px-[10px] text-[0.86rem] text-black placeholder:text-[#6c757d] focus:border-current focus:shadow-none focus:ring-0" maxLength={ maxChatLength } placeholder={ LocalizeText('widgets.chatinput.default') } type="text" value={ chatValue } onChange={ event => updateChatInput(event.target.value) } onMouseDown={ event => setInputFocus() } />
</div> }
{ floodBlocked &&
<div className="flex min-w-0 flex-1 items-center px-[10px]">
<Text variant="danger" className="w-full truncate whitespace-nowrap text-[0.8rem] font-bold leading-none">{ LocalizeText('chat.input.alert.flood', [ 'time' ], [ floodBlockedSeconds.toString() ]) }</Text>
</div> }
<ChatInputEmojiSelectorView addChatEmoji={ addChatEmoji } />
</div>, document.getElementById('toolbar-chat-input-container')) </div>, document.getElementById('toolbar-chat-input-container'))
); );
}; };
+58 -63
View File
@@ -1,7 +1,7 @@
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, Dispose, DropBounce, EaseOut, FindNewFriendsMessageComposer, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion, Variants } from 'framer-motion'; import { AnimatePresence, motion, Variants } from 'framer-motion';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FC, useEffect, useMemo, useState } from 'react';
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, SendMessageComposer, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMentionsSnapshot, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMentionsSnapshot, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks';
import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarItemView } from './ToolbarItemView';
@@ -26,14 +26,14 @@ const shellVariants: Variants = {
const SHELL_TRANSITION = { type: 'spring' as const, stiffness: 260, damping: 26 }; const SHELL_TRANSITION = { type: 'spring' as const, stiffness: 260, damping: 26 };
const NAV_TRANSITION = { type: 'spring' as const, stiffness: 300, damping: 28 }; const NAV_TRANSITION = { type: 'spring' as const, stiffness: 300, damping: 28 };
const ME_POPOVER_TRANSITION = { type: 'spring' as const, stiffness: 420, damping: 28 }; const ME_POPOVER_TRANSITION = { type: 'spring' as const, stiffness: 420, damping: 28 };
const TOGGLE_LOCK_MS = 220;
export const ToolbarView: FC<{ isInRoom: boolean }> = props => export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ {
const { isInRoom } = props; const { isInRoom } = props;
const [ isMeExpanded, setMeExpanded ] = useState(false); const [ isMeExpanded, setMeExpanded ] = useState(false);
const [ isToolbarOpen, setIsToolbarOpen ] = useState(false);
const [ isTouchLayout, setIsTouchLayout ] = useState(false); const [ isTouchLayout, setIsTouchLayout ] = useState(false);
const [ leftCollapsed, setLeftCollapsed ] = useState(false);
const [ rightCollapsed, setRightCollapsed ] = useState(false);
const [ staffStackBottom, setStaffStackBottom ] = useState<number | null>(null); const [ staffStackBottom, setStaffStackBottom ] = useState<number | null>(null);
const [ useGuideTool, setUseGuideTool ] = useState(false); const [ useGuideTool, setUseGuideTool ] = useState(false);
const [ youtubeEnabled, setYoutubeEnabled ] = useState(false); const [ youtubeEnabled, setYoutubeEnabled ] = useState(false);
@@ -54,26 +54,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
() => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0, () => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0,
[ isMod, tickets ] [ isMod, tickets ]
); );
const isVisible = (isToolbarOpen || !isInRoom); const visibilityVariant = 'visible';
const visibilityVariant = isVisible ? 'visible' : 'hidden';
const toggleLockRef = useRef(false);
const toggleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => const compactFramePosition = 'bottom-[90px] min-[1700px]:bottom-[7px]';
{
if(toggleTimeoutRef.current) clearTimeout(toggleTimeoutRef.current);
}, []);
const handleToggleClick = useCallback(() =>
{
if(toggleLockRef.current) return;
toggleLockRef.current = true;
setIsToolbarOpen(value => !value);
if(toggleTimeoutRef.current) clearTimeout(toggleTimeoutRef.current);
toggleTimeoutRef.current = setTimeout(() => { toggleLockRef.current = false; }, TOGGLE_LOCK_MS);
}, []);
const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[90px] min-[1700px]:bottom-0' : 'bottom-0';
const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1700px]:hidden'; const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1700px]:hidden';
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:block'; const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:block';
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:flex'; const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:flex';
@@ -196,20 +179,6 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ isInRoom && { isInRoom &&
<div className={ `tb-frame fixed ${ compactFramePosition } left-1/2 -translate-x-1/2 z-40 flex h-[38px] w-[420px] max-w-[95vw] items-center px-[6px] py-[4px] pointer-events-none` }> <div className={ `tb-frame fixed ${ compactFramePosition } left-1/2 -translate-x-1/2 z-40 flex h-[38px] w-[420px] max-w-[95vw] items-center px-[6px] py-[4px] pointer-events-none` }>
<motion.div
className="tb-toggle pointer-events-auto mr-2 flex-shrink-0"
onClick={ handleToggleClick }
whileTap={ { scale: 0.9 } }>
<motion.svg
className="h-3.5 w-3.5 text-white/70"
animate={ { rotate: isToolbarOpen ? 180 : 0 } }
transition={ { type: 'spring', stiffness: 320, damping: 24 } }
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 2.5 } d="M5 15l7-7 7 7" />
</motion.svg>
</motion.div>
<Flex <Flex
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
@@ -218,21 +187,31 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</div> } </div> }
<motion.div <motion.div
initial="hidden" initial="visible"
animate={ visibilityVariant } animate={ visibilityVariant }
variants={ shellVariants } variants={ shellVariants }
transition={ SHELL_TRANSITION } transition={ SHELL_TRANSITION }
className={ `pointer-events-none fixed bottom-0 left-0 right-0 z-[39] h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] ${ desktopBlockClasses }` } /> className={ `pointer-events-none fixed bottom-0 left-0 right-0 z-[39] h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(62,64,72,0.55)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] ${ desktopBlockClasses }` } />
<motion.div <motion.div
initial="hidden" initial="visible"
animate={ visibilityVariant } animate={ visibilityVariant }
variants={ leftNavVariants } variants={ leftNavVariants }
transition={ NAV_TRANSITION } transition={ NAV_TRANSITION }
className={ `tb-nav-clip fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pl-3 ${ desktopFlexClasses }` }> className={ `tb-nav-clip fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pl-3 ${ desktopFlexClasses }` }>
<button
type="button"
onClick={ () => setLeftCollapsed(value => !value) }
aria-label="Mostra/Nascondi icone"
className="tb-collapse pointer-events-auto mt-[6px] mr-[3px]">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 3 } d={ leftCollapsed ? 'M9 5l7 7-7 7' : 'M15 19l-7-7 7-7' } />
</svg>
</button>
<motion.div <motion.div
variants={ containerVariants } variants={ containerVariants }
className="tb-open-shell flex h-[52px] max-w-full items-center gap-2 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]"> className="tb-open-shell flex h-[52px] max-w-full items-center gap-2 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]">
{ !leftCollapsed && (<>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
{ isInRoom { isInRoom
? <ToolbarItemView icon="habbo" onClick={ () => VisitDesktop() } className="tb-icon" /> ? <ToolbarItemView icon="habbo" onClick={ () => VisitDesktop() } className="tb-icon" />
@@ -245,6 +224,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" /> <ToolbarItemView icon="game" onClick={ () => CreateLinkEvent('games/toggle') } className="tb-icon" />
</motion.div> } </motion.div> }
</>) }
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> <ToolbarItemView icon="catalog" onClick={ () => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" />
</motion.div> </motion.div>
@@ -282,6 +262,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (getFullCount > 0) && { (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> } <LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div> </motion.div>
{ !leftCollapsed && (<>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> <ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div> </motion.div>
@@ -292,10 +273,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" /> <ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
</motion.div> } </motion.div> }
</>) }
{ isInRoom && { isInRoom &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" /> <ToolbarItemView icon="camera" onClick={ () => CreateLinkEvent('camera/toggle') } className="tb-icon" />
</motion.div> } </motion.div> }
{ !leftCollapsed && (<>
{ (isInRoom && youtubeEnabled) && { (isInRoom && youtubeEnabled) &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" /> <ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
@@ -304,6 +287,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> <ToolbarItemView icon="soundboard" onClick={ () => CreateLinkEvent('soundboard/toggle') } className="tb-icon" />
</motion.div> } </motion.div> }
</>) }
{ isMod && { isMod &&
<motion.div variants={ itemVariants } className="relative"> <motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> <ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
@@ -321,7 +305,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div> </motion.div>
</motion.div> </motion.div>
<motion.div <motion.div
initial="hidden" initial="visible"
animate={ visibilityVariant } animate={ visibilityVariant }
variants={ rightNavVariants } variants={ rightNavVariants }
transition={ NAV_TRANSITION } transition={ NAV_TRANSITION }
@@ -334,6 +318,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (requests.length > 0) && { (requests.length > 0) &&
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> } <LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
</motion.div> </motion.div>
{ rightCollapsed &&
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="friendsearch" onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) } className="tb-icon" />
</motion.div> }
{ !rightCollapsed && (<>
{ mentionsEnabled && { mentionsEnabled &&
<motion.div variants={ itemVariants } className="relative"> <motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="mentions" onClick={ () => CreateLinkEvent('mentions/toggle') } className="tb-icon" /> <ToolbarItemView icon="mentions" onClick={ () => CreateLinkEvent('mentions/toggle') } className="tb-icon" />
@@ -346,14 +335,24 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
</motion.div> } </motion.div> }
<div className={ `mx-1 h-5 w-[1px] bg-white/20 ${ desktopBlockClasses }` } /> <div className={ `mx-1 h-5 w-[1px] bg-white/20 ${ desktopBlockClasses }` } />
<div className={ `h-full shrink-0 ${ desktopBlockClasses }` } id="toolbar-friend-bar-container-desktop" /> <div className={ `h-full shrink-0 ${ desktopBlockClasses }` } id="toolbar-friend-bar-container-desktop" />
</>) }
</motion.div> </motion.div>
<button
type="button"
onClick={ () => setRightCollapsed(value => !value) }
aria-label="Mostra/Nascondi icone"
className="tb-collapse pointer-events-auto mt-[6px] ml-[3px]">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 3 } d={ rightCollapsed ? 'M15 19l-7-7 7-7' : 'M9 5l7 7-7 7' } />
</svg>
</button>
</motion.div> </motion.div>
<motion.div <motion.div
initial="hidden" initial="visible"
animate={ visibilityVariant } animate={ visibilityVariant }
variants={ mobileNavVariants } variants={ mobileNavVariants }
transition={ NAV_TRANSITION } transition={ NAV_TRANSITION }
className={ `fixed left-1/2 bottom-0 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible ${ mobileOnlyClasses } ${ isInRoom ? 'rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] mb-[3px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : '' }` }> className={ `fixed left-1/2 bottom-0 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible ${ mobileOnlyClasses } ${ isInRoom ? 'rounded-[12px] border border-white/8 bg-[rgba(62,64,72,0.55)] px-[6px] py-[4px] mb-[3px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : '' }` }>
<motion.div <motion.div
variants={ containerVariants } variants={ containerVariants }
className="tb-bar-scroll flex h-full min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-visible px-1"> className="tb-bar-scroll flex h-full min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-visible px-1">
@@ -443,12 +442,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
room. Always present (Builders Club), plus camera in-room room. Always present (Builders Club), plus camera in-room
and the staff-only tools when permitted. */ } and the staff-only tools when permitted. */ }
<motion.div <motion.div
initial="hidden" initial="visible"
animate={ visibilityVariant } animate={ visibilityVariant }
variants={ mobileNavVariants } variants={ mobileNavVariants }
transition={ NAV_TRANSITION } transition={ NAV_TRANSITION }
style={ staffStackBottom != null ? { top: 'auto', bottom: `${ staffStackBottom }px` } : undefined } style={ staffStackBottom != null ? { top: 'auto', bottom: `${ staffStackBottom }px` } : undefined }
className={ `fixed left-1 z-40 flex flex-col items-center gap-2 rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[4px] py-[6px] shadow-[0_6px_18px_rgba(0,0,0,0.18)] ${ staffStackBottom == null ? 'top-1/2 -translate-y-1/2' : '' } ${ mobileOnlyClasses }` }> className={ `fixed left-1 z-40 flex flex-col items-center gap-2 rounded-[12px] border border-white/8 bg-[rgba(62,64,72,0.55)] px-[4px] py-[6px] shadow-[0_6px_18px_rgba(0,0,0,0.18)] ${ staffStackBottom == null ? 'top-1/2 -translate-y-1/2' : '' } ${ mobileOnlyClasses }` }>
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> <ToolbarItemView icon="buildersclub" onClick={ () => CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" />
</motion.div> </motion.div>
@@ -499,9 +498,7 @@ const TOOLBAR_STYLES = `
Negative inset margins on the clip path keep vertical breathing Negative inset margins on the clip path keep vertical breathing
room for the popover even on engines that fall back to 'hidden'. */ room for the popover even on engines that fall back to 'hidden'. */
.tb-nav-clip { .tb-nav-clip {
overflow-x: clip; overflow: visible;
overflow-y: visible;
overflow-clip-margin: 0 0 200px 0;
} }
.tb-icon { .tb-icon {
@@ -518,26 +515,24 @@ const TOOLBAR_STYLES = `
transform: translateY(0); transform: translateY(0);
} }
.tb-toggle { .tb-collapse {
width: 32px; width: 15px;
height: 32px; height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: rgba(18, 16, 14, 0.80);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
border-radius: 6px;
background: rgba(62, 64, 72, 0.55);
border: 1px solid rgba(255, 255, 255, 0.10);
color: rgba(255, 255, 255, 0.70);
cursor: pointer; cursor: pointer;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5); transition: color 0.15s, background 0.15s;
transition: background 0.15s, border-color 0.15s;
} }
.tb-toggle:hover { .tb-collapse:hover {
background: rgba(30, 26, 20, 0.88); color: #fff;
border-color: rgba(255, 255, 255, 0.13); background: rgba(80, 82, 90, 0.65);
} }
.tb-bar-scroll { .tb-bar-scroll {
@@ -2,13 +2,24 @@ import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, NitroSettingsE
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; import { FaUserCog, FaVolumeDown, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api'; import { DispatchMainEvent, DispatchUiEvent, LocalizeText, SendMessageComposer } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks'; import { useCatalogPlaceMultipleItems, useCatalogSkipPurchaseConfirmation, useChatWindow, useMessageEvent } from '../../hooks';
import { classNames } from '../../layout'; import { classNames } from '../../layout';
const localizeWithFallback = (key: string, fallback: string) =>
{
const text = LocalizeText(key);
return (text && text !== key) ? text : fallback;
};
// null = full window (legacy). 'audio' | 'chat' | 'other' = focused section
// opened from the purse gear dropdown.
type SettingsSection = null | 'audio' | 'chat' | 'other';
export const UserSettingsView: FC<{}> = props => export const UserSettingsView: FC<{}> = props =>
{ {
const [ isVisible, setIsVisible ] = useState(false); const [ isVisible, setIsVisible ] = useState(false);
const [ section, setSection ] = useState<SettingsSection>(null);
const [ userSettings, setUserSettings ] = useState<NitroSettingsEvent>(null); const [ userSettings, setUserSettings ] = useState<NitroSettingsEvent>(null);
const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems();
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation();
@@ -100,12 +111,14 @@ export const UserSettingsView: FC<{}> = props =>
switch(parts[1]) switch(parts[1])
{ {
case 'show': case 'show':
setSection((parts[2] as SettingsSection) || null);
setIsVisible(true); setIsVisible(true);
return; return;
case 'hide': case 'hide':
setIsVisible(false); setIsVisible(false);
return; return;
case 'toggle': case 'toggle':
setSection((parts[2] as SettingsSection) || null);
setIsVisible(prevValue => !prevValue); setIsVisible(prevValue => !prevValue);
return; return;
} }
@@ -127,81 +140,104 @@ export const UserSettingsView: FC<{}> = props =>
if(!isVisible || !userSettings) return null; if(!isVisible || !userSettings) return null;
const showChat = (section === null || section === 'chat');
const showOther = (section === null || section === 'other');
const showAudio = (section === null || section === 'audio');
const showAccountLink = (section === null);
const headerText = (section === 'audio')
? localizeWithFallback('widget.memenu.settings.volume', 'Audio settings')
: (section === 'chat')
? localizeWithFallback('room.chat.settings.title', 'Chat settings')
: (section === 'other')
? localizeWithFallback('memenu.settings.other', 'Other settings')
: LocalizeText('widget.memenu.settings.title');
return ( return (
<NitroCardView className="user-settings-window" theme="primary-slim" uniqueKey="user-settings"> <NitroCardView className="user-settings-window" theme="primary-slim" uniqueKey="user-settings">
<NitroCardHeaderView headerText={ LocalizeText('widget.memenu.settings.title') } onCloseClick={ event => processAction('close_view') } /> <NitroCardHeaderView headerText={ headerText } onCloseClick={ event => processAction('close_view') } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black">
<div className="flex flex-col gap-1"> { showChat &&
<div className="flex items-center gap-1">
<input checked={ userSettings.oldChat } className="form-check-input" type="checkbox" onChange={ event => processAction('oldchat', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.chat.prefer.old.chat') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ userSettings.roomInvites } className="form-check-input" type="checkbox" onChange={ event => processAction('room_invites', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.ignore.room.invites') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ userSettings.cameraFollow } className="form-check-input" type="checkbox" onChange={ event => processAction('camera_follow', event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.disable.room.camera.follow') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ catalogPlaceMultipleObjects } className="form-check-input" type="checkbox" onChange={ event => setCatalogPlaceMultipleObjects(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.place.multiple.objects') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ catalogSkipPurchaseConfirmation } className="form-check-input" type="checkbox" onChange={ event => setCatalogSkipPurchaseConfirmation(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.skip.purchase.confirmation') }</Text>
</div>
<div className="flex items-center gap-1">
<input checked={ chatWindowEnabled } className="form-check-input" type="checkbox" onChange={ event => setChatWindowEnabled(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.enable.chat.window') }</Text>
</div>
</div>
<div className="flex flex-col">
<Text bold>{ LocalizeText('widget.memenu.settings.volume') }</Text>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.ui') }</Text>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{ (userSettings.volumeSystem === 0) && <FaVolumeMute className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> } <input checked={ userSettings.oldChat } className="form-check-input" type="checkbox" onChange={ event => processAction('oldchat', event.target.checked) } />
{ (userSettings.volumeSystem > 0) && <FaVolumeDown className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> } <Text>{ LocalizeText('memenu.settings.chat.prefer.old.chat') }</Text>
<input className="custom-range w-full" id="volumeSystem" max="100" min="0" step="1" type="range" value={ userSettings.volumeSystem } onChange={ event => processAction('system_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeSystem < 50) && 'text-muted', 'fa-icon') } />
</div> </div>
</div> <div className="flex items-center gap-1">
<input checked={ chatWindowEnabled } className="form-check-input" type="checkbox" onChange={ event => setChatWindowEnabled(event.target.checked) } />
<Text>{ LocalizeText('memenu.settings.other.enable.chat.window') }</Text>
</div>
</div> }
{ showOther &&
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.furni') }</Text>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{ (userSettings.volumeFurni === 0) && <FaVolumeMute className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> } <input checked={ userSettings.roomInvites } className="form-check-input" type="checkbox" onChange={ event => processAction('room_invites', event.target.checked) } />
{ (userSettings.volumeFurni > 0) && <FaVolumeDown className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> } <Text>{ LocalizeText('memenu.settings.other.ignore.room.invites') }</Text>
<input className="custom-range w-full" id="volumeFurni" max="100" min="0" step="1" type="range" value={ userSettings.volumeFurni } onChange={ event => processAction('furni_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeFurni < 50) && 'text-muted', 'fa-icon') } />
</div> </div>
</div>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.trax') }</Text>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{ (userSettings.volumeTrax === 0) && <FaVolumeMute className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> } <input checked={ userSettings.cameraFollow } className="form-check-input" type="checkbox" onChange={ event => processAction('camera_follow', event.target.checked) } />
{ (userSettings.volumeTrax > 0) && <FaVolumeDown className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> } <Text>{ LocalizeText('memenu.settings.other.disable.room.camera.follow') }</Text>
<input className="custom-range w-full" id="volumeTrax" max="100" min="0" step="1" type="range" value={ userSettings.volumeTrax } onChange={ event => processAction('trax_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeTrax < 50) && 'text-muted', 'fa-icon') } />
</div> </div>
</div> <div className="flex items-center gap-1">
</div> <input checked={ catalogPlaceMultipleObjects } className="form-check-input" type="checkbox" onChange={ event => setCatalogPlaceMultipleObjects(event.target.checked) } />
<div className="flex flex-col pt-2 mt-1 border-t border-black/10"> <Text>{ LocalizeText('memenu.settings.other.place.multiple.objects') }</Text>
<button
type="button"
onClick={ () => CreateLinkEvent('user-account-settings/show') }
className="group flex items-center gap-2 rounded-md border border-black/10 bg-white px-2 py-1.5 hover:bg-[#f5fbfd] hover:border-[#1e7295] transition-colors cursor-pointer text-left">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-[#1e7295] text-white shadow-[inset_0_2px_#ffffff26,inset_0_-2px_#0000001a]">
<FaUserCog size={ 12 } />
</div> </div>
<div className="flex flex-col flex-1 leading-tight"> <div className="flex items-center gap-1">
<Text bold>{ LocalizeText('usersettings.open.title') }</Text> <input checked={ catalogSkipPurchaseConfirmation } className="form-check-input" type="checkbox" onChange={ event => setCatalogSkipPurchaseConfirmation(event.target.checked) } />
<Text small className="text-black/60">{ LocalizeText('usersettings.open.subtitle') }</Text> <Text>{ LocalizeText('memenu.settings.other.skip.purchase.confirmation') }</Text>
</div> </div>
<span className="text-black/30 group-hover:text-[#1e7295] text-[10px]"></span> </div> }
</button> { showAudio &&
</div> <div className="flex flex-col">
<Text bold>{ LocalizeText('widget.memenu.settings.volume') }</Text>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.ui') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeSystem === 0) && <FaVolumeMute className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeSystem > 0) && <FaVolumeDown className={ classNames((userSettings.volumeSystem >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeSystem" max="100" min="0" step="1" type="range" value={ userSettings.volumeSystem } onChange={ event => processAction('system_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeSystem < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.furni') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeFurni === 0) && <FaVolumeMute className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeFurni > 0) && <FaVolumeDown className={ classNames((userSettings.volumeFurni >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeFurni" max="100" min="0" step="1" type="range" value={ userSettings.volumeFurni } onChange={ event => processAction('furni_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeFurni < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
<div className="flex flex-col gap-1">
<Text>{ LocalizeText('widget.memenu.settings.volume.trax') }</Text>
<div className="flex items-center gap-1">
{ (userSettings.volumeTrax === 0) && <FaVolumeMute className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> }
{ (userSettings.volumeTrax > 0) && <FaVolumeDown className={ classNames((userSettings.volumeTrax >= 50) && 'text-muted', 'fa-icon') } /> }
<input className="custom-range w-full" id="volumeTrax" max="100" min="0" step="1" type="range" value={ userSettings.volumeTrax } onChange={ event => processAction('trax_volume', event.target.value) } onMouseUp={ () => saveRangeSlider('volume') } />
<FaVolumeUp className={ classNames((userSettings.volumeTrax < 50) && 'text-muted', 'fa-icon') } />
</div>
</div>
</div> }
{ showAccountLink &&
<div className="flex flex-col pt-2 mt-1 border-t border-black/10">
<button
type="button"
onClick={ () => CreateLinkEvent('user-account-settings/show') }
className="group flex items-center gap-2 rounded-md border border-black/10 bg-white px-2 py-1.5 hover:bg-[#f5fbfd] hover:border-[#1e7295] transition-colors cursor-pointer text-left">
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-[#1e7295] text-white shadow-[inset_0_2px_#ffffff26,inset_0_-2px_#0000001a]">
<FaUserCog size={ 12 } />
</div>
<div className="flex flex-col flex-1 leading-tight">
<Text bold>{ LocalizeText('usersettings.open.title') }</Text>
<Text small className="text-black/60">{ LocalizeText('usersettings.open.subtitle') }</Text>
</div>
<span className="text-black/30 group-hover:text-[#1e7295] text-[10px]"></span>
</button>
</div> }
{ (section !== null) &&
<div className="flex pt-2 mt-1 border-t border-black/10">
<Button variant="secondary" onClick={ event => processAction('close_view') }>{ localizeWithFallback('generic.back', 'Indietro') }</Button>
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
+83
View File
@@ -0,0 +1,83 @@
/* Aiuto (Help) + Sanctions windows — custom hotel restyle.
Global (not an inline <style>) so it applies to BOTH the HelpView card and
the separate SanctionStatusView card, regardless of which is mounted. */
/* Classic blue header + light-grey body (the shared 'primary-slim' theme is
teal + cream). Higher specificity than the theme so it wins. The body element
renders `.nitro-card-content-shell`, NOT `.content-area`. */
.nitro-card.nitro-help .nitro-card-header {
background: linear-gradient(180deg, #5a80b8 0%, #3f63a0 100%);
border-color: #34548a;
}
.nitro-card.nitro-help,
.nitro-card.nitro-help .content-area,
.nitro-card.nitro-help .nitro-card-content-shell {
background: #ebebe3 !important;
}
/* Beveled Habbo green button (matches the reference screenshots — the flat
Bootstrap 'success' variant did not). Reused by the Help action buttons and
the Sanctions "Ho capito" button. */
.habbo-btn-green {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 34px;
padding: 6px 14px;
border: 1px solid #4f7a22;
border-radius: 6px;
background: linear-gradient(180deg, #72b03a 0%, #5a8c2a 100%);
color: #ffffff;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.1;
text-align: center;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -2px 0 rgba(0, 0, 0, 0.16);
transition: filter 0.15s ease;
}
.habbo-btn-green:hover { filter: brightness(1.06); }
.habbo-btn-green:active { filter: brightness(0.95); }
.habbo-btn-green:disabled {
background: linear-gradient(180deg, #cfcfcf 0%, #b6b6b6 100%);
border-color: #9a9a9a;
color: #f0f0f0;
cursor: default;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
/* Auto-width variant for footer buttons (e.g. "Ho capito"). */
.habbo-btn-green--auto {
width: auto;
min-width: 96px;
}
/* Green-arrow safety/status links at the bottom of the help windows. */
.help-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 0;
border: 0;
background: transparent;
color: #2f2f2f;
font-size: 0.78rem;
font-weight: 700;
text-decoration: underline;
text-align: left;
cursor: pointer;
}
.help-link:hover { filter: brightness(1.15); }
.help-link__icon {
flex: 0 0 auto;
color: #46a01e;
font-size: 0.95rem;
}
-420
View File
@@ -1,420 +0,0 @@
/* Classic (original) purse style. All selectors are scoped under
.nitro-purse-classic so they never collide with the modern PurseView.css
rules that share class names like .nitro-purse. */
.nitro-purse-classic {
width: 100%;
}
/* Extra (seasonal) currency in classic mode reuses the modern boxed
.nitro-purse__other styling, just constrained to the classic purse width. */
.nitro-purse__other--classic {
max-width: 125px;
}
/* The #41403c border on the extra-currency box is new-style-only;
classic mode has no border. */
.nitro-purse__other--classic .nitro-purse-seasonal-currency {
border: 0;
}
.nitro-purse-classic .nitro-purse-shell {
width: 100%;
max-width: 188px;
margin-top: 6px;
margin-left: auto;
}
.nitro-purse-classic .nitro-purse-shell.is-closed {
width: 52px;
max-width: 52px;
}
.nitro-purse-classic .nitro-purse {
width: 100%;
max-width: none;
margin: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
background: rgba(10, 10, 12, 0.58);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 8px 18px rgba(0, 0, 0, 0.14);
transition: width 0.24s cubic-bezier(0.22, 1, 0.36, 1), max-width 0.24s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.2s ease;
}
.nitro-purse-classic .nitro-purse.is-closed {
width: 52px;
}
.nitro-purse-classic .nitro-purse__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 5px 7px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.nitro-purse-classic .nitro-purse__header.is-closed {
justify-content: flex-end;
gap: 5px;
padding: 5px 6px;
}
.nitro-purse-classic .nitro-purse__header-main {
display: inline-flex;
align-items: center;
}
.nitro-purse-classic .nitro-purse__header-main.is-closed {
margin-right: 0;
}
.nitro-purse-classic .nitro-purse__header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
opacity: 0.9;
}
.nitro-purse-classic .nitro-purse__header-image {
display: block;
width: auto;
height: 14px;
object-fit: contain;
}
.nitro-purse-classic .nitro-purse__header-title {
font-size: 0.82rem;
font-weight: 700;
line-height: 1;
color: rgba(255, 255, 255, 0.92) !important;
letter-spacing: 0.01em;
}
.nitro-purse-classic .nitro-purse__header-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.2s ease;
}
.nitro-purse-classic .nitro-purse__header-toggle.is-open {
transform: rotate(180deg);
}
.nitro-purse-classic .nitro-purse__content {
display: flex;
flex-direction: column;
gap: 5px;
padding: 6px;
overflow: hidden;
transform-origin: top;
transition:
max-height 0.58s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.38s ease-out,
transform 0.58s cubic-bezier(0.16, 1, 0.3, 1),
padding 0.4s ease-out;
max-height: 280px;
opacity: 1;
transform: translateY(0);
background: transparent;
}
.nitro-purse-classic .nitro-purse__content.is-closed {
max-height: 0;
opacity: 0;
transform: translateY(-8px) scaleY(0.95);
padding-top: 0;
padding-bottom: 0;
pointer-events: none;
}
.nitro-purse-classic .nitro-purse__summary,
.nitro-purse-classic .nitro-purse__seasonal {
transition:
opacity 0.32s ease-out,
transform 0.48s cubic-bezier(0.16, 1, 0.3, 1);
}
.nitro-purse-classic .nitro-purse__summary {
transition-delay: 0.08s;
}
.nitro-purse-classic .nitro-purse__seasonal {
transition-delay: 0.16s;
}
.nitro-purse-classic .nitro-purse__content.is-closed .nitro-purse__summary,
.nitro-purse-classic .nitro-purse__content.is-closed .nitro-purse__seasonal {
opacity: 0;
transform: translateY(-6px);
transition-delay: 0s;
}
.nitro-purse-classic .nitro-purse__summary {
display: grid;
grid-template-columns: minmax(0, 1fr) 30px 26px;
gap: 5px;
align-items: stretch;
}
.nitro-purse-classic .nitro-purse__summary.is-no-hc {
grid-template-columns: minmax(0, 1fr) 26px;
}
.nitro-purse-classic .nitro-purse__primary,
.nitro-purse-classic .nitro-purse__seasonal {
display: flex;
flex-direction: column;
gap: 3px;
}
.nitro-purse-classic .nitro-purse .nitro-purse-button,
.nitro-purse-classic .nitro-purse-seasonal-currency {
min-height: 22px;
padding: 2px 0;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.nitro-purse-classic .nitro-purse .allcurrencypurse,
.nitro-purse-classic .nitro-purse-seasonal-currency {
position: relative;
}
.nitro-purse-classic .nitro-purse .allcurrencypurse::after,
.nitro-purse-classic .nitro-purse-seasonal-currency::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: rgba(255, 255, 255, 0.08);
}
.nitro-purse-classic .nitro-purse__primary > :last-child::after,
.nitro-purse-classic .nitro-purse__seasonal > :last-child::after {
display: none;
}
.nitro-purse-classic .nitro-purse .allcurrencypurse .text-white {
font-size: 0.76rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.88) !important;
}
.nitro-purse-classic .nitro-purse .nitro-purse-button.currency--1 .text-white {
color: #7fdcff !important;
}
.nitro-purse-classic .nitro-purse .nitro-purse-button.currency-0 .text-white {
color: #ffd76d !important;
}
.nitro-purse-classic .nitro-purse .nitro-purse-button.currency-5 .text-white {
color: #df95ff !important;
}
.nitro-purse-classic .nitro-purse-subscription {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 62px;
cursor: pointer;
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
}
.nitro-purse-classic .nitro-purse-subscription__icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
opacity: 0.95;
}
.nitro-purse-classic .nitro-purse-subscription__copy {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1px;
}
.nitro-purse-classic .nitro-purse-subscription__label {
font-size: 0.5rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.62) !important;
letter-spacing: 0.08em;
}
.nitro-purse-classic .nitro-purse-subscription__value {
font-size: 0.54rem;
font-weight: 700;
line-height: 1.05;
color: #ffffff !important;
}
.nitro-purse-classic .nitro-purse__actions {
display: grid;
grid-template-columns: 1fr;
gap: 3px;
}
.nitro-purse-classic .nitro-purse__action-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 20px;
padding: 0;
border: 1px solid rgba(7, 23, 31, 0.82);
border-radius: 7px;
color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.05);
box-shadow: none;
transition: background-color 0.18s ease, transform 0.18s ease;
}
.nitro-purse-classic .nitro-purse__action-button:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.nitro-purse-classic .nitro-purse__action-button .nitro-icon {
transform: scale(0.82);
}
.nitro-purse-classic .nitro-purse-seasonal-currency > div {
align-items: center;
gap: 6px;
padding: 0;
}
.nitro-purse-classic .seasonal-row {
min-width: 0;
}
.nitro-purse-classic .seasonal-text-padding,
.nitro-purse-classic .seasonal-amount {
display: flex;
align-items: center;
margin-left: 0;
}
.nitro-purse-classic .seasonal-text {
min-width: 0;
font-size: 0.76rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.76) !important;
line-height: 1;
letter-spacing: 0.01em;
}
.nitro-purse-classic .seasonal-amount {
margin-left: auto;
white-space: nowrap;
flex: 0 0 auto;
font-size: 0.76rem;
font-weight: 700;
line-height: 1;
color: rgba(255, 255, 255, 0.96) !important;
}
.nitro-purse-classic .seasonal-image-padding {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 0;
background: transparent;
flex: 0 0 auto;
}
.nitro-purse-classic .seasonal-image {
display: block;
width: auto;
height: 14px;
object-fit: contain;
}
@media (max-width: 640px) {
.nitro-purse-classic .nitro-purse-shell {
max-width: 100%;
}
.nitro-purse-classic .nitro-purse-shell.is-closed {
max-width: 52px;
}
.nitro-purse-classic .nitro-purse {
border-radius: 9px;
}
.nitro-purse-classic .nitro-purse__content {
padding: 6px;
}
.nitro-purse-classic .nitro-purse__summary {
grid-template-columns: minmax(0, 1fr) 28px 24px;
gap: 4px;
}
.nitro-purse-classic .nitro-purse__summary.is-no-hc {
grid-template-columns: minmax(0, 1fr) 24px;
}
.nitro-purse-classic .nitro-purse-subscription {
min-height: 58px;
}
.nitro-purse-classic .nitro-purse .allcurrencypurse .text-white,
.nitro-purse-classic .seasonal-text,
.nitro-purse-classic .seasonal-amount {
font-size: 0.72rem;
}
.nitro-purse-classic .nitro-purse__header-title {
font-size: 0.78rem;
}
}
@media (max-width: 420px) {
.nitro-purse-classic .nitro-purse__summary {
grid-template-columns: minmax(0, 1fr) 26px 22px;
gap: 4px;
}
.nitro-purse-classic .nitro-purse__summary.is-no-hc {
grid-template-columns: minmax(0, 1fr) 22px;
}
.nitro-purse-classic .nitro-purse-subscription {
min-height: 54px;
}
.nitro-purse-classic .nitro-purse-subscription__value {
font-size: 0.55rem;
}
}
+44 -4
View File
@@ -121,13 +121,13 @@
} }
.nitro-purse__btn--join { .nitro-purse__btn--join {
border-color: rgba(255, 255, 255, 0.1); border-color: #4f7a22;
background: #33312c; background: linear-gradient(180deg, #72b03a 0%, #5a8c2a 100%);
} }
.nitro-purse__btn--earnings { .nitro-purse__btn--earnings {
border-color: rgba(255, 255, 255, 0.1); border-color: #4f7a22;
background: #33312c; background: linear-gradient(180deg, #72b03a 0%, #5a8c2a 100%);
} }
.nitro-purse__btn--help { .nitro-purse__btn--help {
@@ -230,6 +230,46 @@
object-fit: contain; object-fit: contain;
} }
/* ---- Settings dropdown (gear menu) ---- */
.nitro-purse-menu {
width: 100%;
max-width: 234px;
margin-top: 4px;
margin-left: auto;
display: flex;
flex-direction: column;
overflow: hidden;
border: 2px solid #41403c;
border-radius: 8px;
background: rgba(10, 10, 12, 0.92);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.3);
pointer-events: all;
}
.nitro-purse-menu__item {
padding: 2px 10px;
text-align: left;
font-size: 0.78rem;
font-weight: 500;
line-height: 1.3;
color: rgba(255, 255, 255, 0.9);
background: transparent;
border: 0;
cursor: pointer;
transition: background 0.12s ease;
}
.nitro-purse-menu__item:hover {
background: rgba(255, 255, 255, 0.08);
}
.nitro-purse-menu__item--disabled,
.nitro-purse-menu__item--disabled:hover {
color: rgba(255, 255, 255, 0.35);
background: transparent;
cursor: default;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.nitro-purse { .nitro-purse {
max-width: 100%; max-width: 100%;
+2
View File
@@ -37,6 +37,8 @@ import './css/friends/FriendsView.css';
import './css/habbo/HabboSwfSkin.css'; import './css/habbo/HabboSwfSkin.css';
import './css/help/HelpView.css';
import './css/hotelview/HotelView.css'; import './css/hotelview/HotelView.css';
import './css/login/LoginView.css'; import './css/login/LoginView.css';