merge: sync upstream duckietm/Dev (b2318b9) into feat/react19-modernization

Absorbs 10 upstream commits (JSON5 config support, user-settings reset
password/email/username, wear-badge popup fix, login screen fix, About
update, offer selection logic, client path fix).

Conflicts resolved by keeping the modernized React 19 / Zustand / Form
Actions structure and porting upstream intent surgically:

- bootstrap.ts: kept GetConfiguration().init() pre-init + useEffectEvent,
  added JSON5 import (already wired into the parse fallback)
- LoginView.tsx: kept Form Actions (useActionState/useFormStatus); the
  upstream persistAccessTokenFromPayload(payload) fix was already
  integrated in the modernized SSO branch
- App.tsx: kept useEffectEvent import + StrictMode/ErrorBoundary umbrella
- vite.config.mjs: kept sirv plugin + react-compiler babel; absorbed
  upstream's base: process.env.VITE_BASE || './'
- package.json: kept superset (sirv, Vitest, Zustand, react-colorful,
  React Compiler) + added json5
- User-settings views: accepted upstream (duplicate of local cherry-pick
  2053c8e); notification badge bubble: accepted upstream fix

Verification: yarn typecheck clean, 193/193 Vitest, yarn build green.
This commit is contained in:
simoleo89
2026-05-18 20:14:58 +02:00
12 changed files with 505 additions and 20 deletions
@@ -1,4 +1,5 @@
import { NotificationAlertItem, NotificationAlertType } from '../../../../api';
import { NitroInfoAlertView } from './NitroInfoAlertView';
import { NitroSystemAlertView } from './NitroSystemAlertView';
import { NotificationDefaultAlertView } from './NotificationDefaultAlertView';
import { NotificationSeachAlertView } from './NotificationSearchAlertView';
@@ -14,6 +15,8 @@ export const GetAlertLayout = (item: NotificationAlertItem, onClose: () => void)
{
case NotificationAlertType.NITRO:
return <NitroSystemAlertView key={key} {...props} />;
case NotificationAlertType.NITRO_INFO:
return <NitroInfoAlertView key={key} {...props} />;
case NotificationAlertType.SEARCH:
return <NotificationSeachAlertView key={key} {...props} />;
default:
@@ -0,0 +1,154 @@
import { FC, useMemo } from 'react';
import { LocalizeText, NotificationAlertItem, NotificationAlertType, OpenUrl } from '../../../../api';
import { Button, Column, Flex, LayoutAvatarImageView, LayoutNotificationAlertView, LayoutNotificationAlertViewProps, Text } from '../../../../common';
const INFO_AVATAR_FIGURE = 'hr-831-61.hd-180-2.lg-270-100.sh-290-110.ha-3129-100.fa-1205-63.cc-3039-100';
interface NitroInfoAlertViewProps extends LayoutNotificationAlertViewProps
{
item: NotificationAlertItem;
}
const REPORT_ISSUES_URL = 'https://github.com/duckietm/Nitro-V3/issues';
type InfoSection = { title: string; lines: string[]; kind: 'hotel' | 'server' | 'credits' | 'generic' };
const detectKind = (title: string): InfoSection['kind'] =>
{
const t = title.toLowerCase();
if(t.includes('hotel')) return 'hotel';
if(t.includes('server')) return 'server';
if(t.includes('credit')) return 'credits';
return 'generic';
};
const parseInfoSections = (text: string): { version: string; sections: InfoSection[] } =>
{
const version = (text.match(/<b>([^<]+)<\/b>/) || [ '', '' ])[1];
const stripped = text
.replace(/^<b>[^<]+<\/b>\r?\n?/, '')
.replace(/Report issues at:[^]*$/, '');
const sections: InfoSection[] = [];
const blocks = stripped.split(/\r?\n+/);
for(const block of blocks)
{
if(!block.trim()) continue;
const headerMatch = block.match(/<b>([^<]+)<\/b>/);
if(!headerMatch) continue;
const title = headerMatch[1];
const rest = block.substring(block.indexOf('</b>') + 4);
const lines = rest.split(/\r/).map(l => l.trim().replace(/^-\s*/, '')).filter(Boolean);
sections.push({ title, lines, kind: detectKind(title) });
}
return { version, sections };
};
const sectionIcon: Record<InfoSection['kind'], string> = {
hotel: '🏨',
server: '🖥️',
credits: '⭐',
generic: '️'
};
const splitLabel = (line: string): { label: string; value: string } =>
{
const idx = line.indexOf(':');
if(idx === -1) return { label: '', value: line };
return { label: line.substring(0, idx).trim(), value: line.substring(idx + 1).trim() };
};
export const NitroInfoAlertView: FC<NitroInfoAlertViewProps> = props =>
{
const { item = null, title: titleProp = null, onClose = null, ...rest } = props;
const { version, sections } = useMemo(() =>
{
const text = (item && item.messages && item.messages[0]) || '';
return parseInfoSections(text);
}, [ item ]);
const rawTitle = titleProp || (item && item.title) || '';
const displayTitle = (rawTitle && rawTitle !== 'nitro.info.title') ? rawTitle : 'Hotel Info';
return (
<LayoutNotificationAlertView
title={ displayTitle }
onClose={ onClose }
{ ...rest }
type={ NotificationAlertType.NITRO_INFO }>
<div className="nitro-info-hero">
<div className="nitro-info-hero-stars" />
{ version &&
<div className="nitro-info-version-badge">
<span className="nitro-info-version-spark"></span>
<span className="nitro-info-version-text">{ version }</span>
<span className="nitro-info-version-spark"></span>
</div> }
</div>
<Flex fullHeight gap={ 2 } overflow="hidden" className="nitro-info-content">
<div className="nitro-info-avatar-wrap shrink-0">
<LayoutAvatarImageView
figure={ INFO_AVATAR_FIGURE }
direction={ 2 }
classNames={ [ 'nitro-info-avatar' ] } />
<div className="nitro-info-avatar-shadow" />
</div>
<Column fullWidth gap={ 2 } overflow="auto" className="nitro-info-body">
{ sections.map((section, index) => (
<div key={ index } className={ `nitro-info-section nitro-info-section-${ section.kind }` }>
<div className="nitro-info-section-header">
<span className="nitro-info-section-icon">{ sectionIcon[section.kind] }</span>
<span className="nitro-info-section-title">{ section.title }</span>
</div>
<div className="nitro-info-section-body">
{ section.kind === 'credits'
? (
<ul className="nitro-info-credits-list">
{ section.lines.map((line, i) => (
<li key={ i }>
<span className="nitro-info-credit-star"></span>
<span>{ line }</span>
</li>
)) }
</ul>
)
: (
<ul className="nitro-info-stats-list">
{ section.lines.map((line, i) =>
{
const { label, value } = splitLabel(line);
return (
<li key={ i }>
{ label && <span className="nitro-info-stat-label">{ label }</span> }
<span className="nitro-info-stat-value">{ value }</span>
</li>
);
}) }
</ul>
) }
</div>
</div>
)) }
<div className="nitro-info-footer">
<Text center small italics>Found a bug? Help us improve!</Text>
<Flex gap={ 1 } fullWidth className="nitro-info-actions">
<Button fullWidth variant="success" className="nitro-info-report-btn" onClick={ () => OpenUrl(REPORT_ISSUES_URL) }>
<span>🐞 Report Issues</span>
</Button>
<Button fullWidth onClick={ onClose }>
{ LocalizeText('generic.close') }
</Button>
</Flex>
</div>
</Column>
</Flex>
</LayoutNotificationAlertView>
);
};
@@ -1,5 +1,5 @@
import { RequestBadgesComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useEffectEvent } from 'react';
import { FC, useEffect } from 'react';
import { LocalizeText, NotificationBubbleItem, SendMessageComposer } from '../../../../api';
import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common';
import { useInventoryBadges } from '../../../../hooks';
@@ -14,15 +14,10 @@ export const NotificationBadgeReceivedBubbleView: FC<NotificationBadgeReceivedBu
const { item = null, onClose = null, ...rest } = props;
const { activeBadgeCodes = [], toggleBadge = null, isWearingBadge = null, canWearBadges = null } = useInventoryBadges();
const requestBadgesIfEmpty = useEffectEvent(() =>
{
if(activeBadgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer());
});
useEffect(() =>
{
requestBadgesIfEmpty();
}, []);
if(activeBadgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer());
}, [ activeBadgeCodes.length ]);
const badgeCode = item?.linkUrl ?? null;
const isLoaded = activeBadgeCodes.length > 0;