feat: implement lazy loading for components and add loading states for improved user experience

This commit is contained in:
alexsparkes
2026-02-06 17:30:55 +00:00
parent dbfe7e52b5
commit 3c53d0322e
13 changed files with 474 additions and 133 deletions

View File

@@ -0,0 +1,56 @@
import './SettingsLoader.scss';
const SettingsLoader = () => {
return (
<div className="settings-loader">
<div className="loader-header-section">
<div className="loader-header-left">
<div className="loader-title shimmer"></div>
</div>
<div className="loader-header-right">
<div className="loader-button shimmer"></div>
<div className="loader-button shimmer"></div>
</div>
</div>
<div className="loader-section">
<div className="loader-section-title shimmer"></div>
<div className="loader-section-subtitle shimmer"></div>
</div>
<div className="loader-options">
<div className="loader-option-row">
<div className="loader-option-left">
<div className="loader-option-label shimmer"></div>
<div className="loader-option-desc shimmer"></div>
</div>
<div className="loader-toggle shimmer"></div>
</div>
<div className="loader-option-row">
<div className="loader-option-left">
<div className="loader-option-label shimmer"></div>
<div className="loader-option-desc shimmer"></div>
</div>
<div className="loader-toggle shimmer"></div>
</div>
<div className="loader-option-row">
<div className="loader-option-left">
<div className="loader-option-label shimmer"></div>
</div>
<div className="loader-select shimmer"></div>
</div>
<div className="loader-option-row">
<div className="loader-option-left">
<div className="loader-option-label shimmer"></div>
</div>
<div className="loader-slider shimmer"></div>
</div>
</div>
</div>
);
};
export default SettingsLoader;

View File

@@ -0,0 +1,158 @@
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.settings-loader {
padding: 0;
width: 100%;
animation: fadeIn 0.3s ease-in;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.shimmer {
position: relative;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 50%,
transparent 100%
);
animation: shimmer 2s ease-in-out infinite;
}
}
.loader-header-section {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 20px;
.loader-header-left {
.loader-title {
width: 180px;
height: 32px;
}
}
.loader-header-right {
display: flex;
gap: 10px;
.loader-button {
width: 110px;
height: 36px;
border-radius: 8px;
}
}
}
.loader-section {
padding: 20px 30px;
margin-bottom: 25px;
.loader-section-title {
width: 220px;
height: 22px;
margin-bottom: 8px;
}
.loader-section-subtitle {
width: 420px;
height: 16px;
opacity: 0.7;
}
}
.loader-options {
padding: 0 30px 30px;
display: flex;
flex-direction: column;
gap: 28px;
.loader-option-row {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 44px;
.loader-option-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
max-width: 65%;
.loader-option-label {
width: 160px;
height: 18px;
}
.loader-option-desc {
width: 320px;
height: 14px;
opacity: 0.6;
}
}
.loader-toggle {
width: 44px;
height: 24px;
border-radius: 12px;
}
.loader-select {
width: 200px;
height: 40px;
border-radius: 6px;
}
.loader-slider {
width: 280px;
height: 6px;
border-radius: 3px;
}
}
}
}
body.light {
.settings-loader {
.shimmer {
background: rgba(0, 0, 0, 0.04);
&::after {
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 0, 0, 0.08) 50%,
transparent 100%
);
}
}
.loader-header-section {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
}
}

View File

@@ -0,0 +1,19 @@
import './WelcomeLoader.scss';
const WelcomeLoader = () => {
return (
<div className="welcome-loader">
<div className="welcome-loader-content">
<div className="loader-logo shimmer"></div>
<div className="loader-text-line large shimmer"></div>
<div className="loader-text-line medium shimmer"></div>
<div className="loader-button-group">
<div className="loader-button shimmer"></div>
<div className="loader-button shimmer"></div>
</div>
</div>
</div>
);
};
export default WelcomeLoader;

View File

@@ -0,0 +1,85 @@
@keyframes shimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
.welcome-loader {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 500px;
.shimmer {
background: linear-gradient(
to right,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 800px 100px;
animation: shimmer 2s infinite linear;
border-radius: 8px;
}
.welcome-loader-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 25px;
padding: 40px;
.loader-logo {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 10px;
}
.loader-text-line {
height: 20px;
border-radius: 4px;
&.large {
width: 350px;
height: 32px;
}
&.medium {
width: 280px;
height: 18px;
}
}
.loader-button-group {
display: flex;
gap: 15px;
margin-top: 20px;
.loader-button {
width: 120px;
height: 45px;
border-radius: 8px;
}
}
}
}
body.light {
.welcome-loader {
.shimmer {
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.05) 0%,
rgba(0, 0, 0, 0.15) 50%,
rgba(0, 0, 0, 0.05) 100%
);
background-size: 800px 100px;
}
}
}

View File

@@ -1,16 +1,17 @@
import variables from 'config/variables';
import { useState, useEffect } from 'react';
import { useState, useEffect, lazy, Suspense } from 'react';
import Modal from 'react-modal';
import { MainModal } from 'components/Elements';
import Navbar from '../../navbar/Navbar';
import Preview from '../../helpers/preview/Preview';
import WelcomeLoader from '../components/WelcomeLoader';
import EventBus from 'utils/eventbus';
import { parseDeepLink, shouldAutoOpenModal, updateHash } from 'utils/deepLinking';
import { install } from 'utils/marketplace';
import Welcome from 'features/welcome/Welcome';
const Welcome = lazy(() => import('features/welcome/Welcome'));
const DEFAULT_PACK_ID = '0c8a5bdebd13';
@@ -177,7 +178,9 @@ const Modals = () => {
shouldCloseOnOverlayClick={false}
ariaHideApp={false}
>
<Welcome modalClose={() => closeWelcome()} modalSkip={() => previewWelcome()} />
<Suspense fallback={<WelcomeLoader />}>
<Welcome modalClose={() => closeWelcome()} modalSkip={() => previewWelcome()} />
</Suspense>
</Modal>
{preview && <Preview setup={() => window.location.reload()} />}
</>

View File

@@ -1,27 +1,28 @@
import { memo, useMemo } from 'react';
import { memo, useMemo, lazy, Suspense } from 'react';
import { useT } from 'contexts/TranslationContext';
import Tabs from 'components/Elements/MainModal/backend/Tabs';
import SettingsLoader from '../components/SettingsLoader';
import './Settings.scss';
import { NavbarOptions } from 'features/navbar';
import { GreetingOptions } from 'features/greeting';
import { TimeOptions, DateOptions } from 'features/time';
import { QuickLinksOptions } from 'features/quicklinks';
import { QuoteOptions } from 'features/quote';
import { MessageOptions } from 'features/message';
import { BackgroundOptions } from 'features/background';
import { SearchOptions } from 'features/search';
import { WeatherOptions } from 'features/weather';
import { Stats } from 'features/stats';
import {
About,
AdvancedOptions,
AppearanceOptions,
Changelog,
ExperimentalOptions,
LanguageOptions,
Overview,
} from '../sections';
const NavbarOptions = lazy(() => import('features/navbar').then((m) => ({ default: m.NavbarOptions })));
const GreetingOptions = lazy(() => import('features/greeting').then((m) => ({ default: m.GreetingOptions })));
const TimeOptions = lazy(() => import('features/time').then((m) => ({ default: m.TimeOptions })));
const DateOptions = lazy(() => import('features/time').then((m) => ({ default: m.DateOptions })));
const QuickLinksOptions = lazy(() => import('features/quicklinks').then((m) => ({ default: m.QuickLinksOptions })));
const QuoteOptions = lazy(() => import('features/quote').then((m) => ({ default: m.QuoteOptions })));
const MessageOptions = lazy(() => import('features/message').then((m) => ({ default: m.MessageOptions })));
const BackgroundOptions = lazy(() => import('features/background').then((m) => ({ default: m.BackgroundOptions })));
const SearchOptions = lazy(() => import('features/search').then((m) => ({ default: m.SearchOptions })));
const WeatherOptions = lazy(() => import('features/weather').then((m) => ({ default: m.WeatherOptions })));
const Stats = lazy(() => import('features/stats').then((m) => ({ default: m.Stats })));
const About = lazy(() => import('../sections').then((m) => ({ default: m.About })));
const AdvancedOptions = lazy(() => import('../sections').then((m) => ({ default: m.AdvancedOptions })));
const AppearanceOptions = lazy(() => import('../sections').then((m) => ({ default: m.AppearanceOptions })));
const Changelog = lazy(() => import('../sections').then((m) => ({ default: m.Changelog })));
const ExperimentalOptions = lazy(() => import('../sections').then((m) => ({ default: m.ExperimentalOptions })));
const LanguageOptions = lazy(() => import('../sections').then((m) => ({ default: m.LanguageOptions })));
const Overview = lazy(() => import('../sections').then((m) => ({ default: m.Overview })));
const sections = [
{ label: 'modals.main.settings.sections.order.title', name: 'order', component: Overview },
@@ -114,11 +115,15 @@ function Settings(props) {
>
{translatedSections.map(({ label, name, component: Component, translatedLabel }) => (
<div key={name} label={translatedLabel} name={name}>
<Component
currentSubSection={props.currentSubSection}
onSubSectionChange={props.onSubSectionChange}
sectionName={name}
/>
<Suspense fallback={<SettingsLoader />}>
<div className="settings-content-wrapper">
<Component
currentSubSection={props.currentSubSection}
onSubSectionChange={props.onSubSectionChange}
sectionName={name}
/>
</div>
</Suspense>
</div>
))}
</Tabs>

View File

@@ -0,0 +1,13 @@
@keyframes settingsFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.settings-content-wrapper {
animation: settingsFadeIn 0.3s ease-out;
animation-fill-mode: both;
}

View File

@@ -1,83 +1,50 @@
import variables from 'config/variables';
import * as ar from 'i18n/locales/achievements/ar.json';
import * as arz from 'i18n/locales/achievements/arz.json';
import * as az from 'i18n/locales/achievements/az.json';
import * as azb from 'i18n/locales/achievements/azb.json';
import * as bn from 'i18n/locales/achievements/bn.json';
import * as de_DE from 'i18n/locales/achievements/de_DE.json';
import * as el from 'i18n/locales/achievements/el.json';
import * as en_GB from 'i18n/locales/achievements/en_GB.json';
import * as en_US from 'i18n/locales/achievements/en_US.json';
import * as es from 'i18n/locales/achievements/es.json';
import * as es_419 from 'i18n/locales/achievements/es_419.json';
import * as et from 'i18n/locales/achievements/et.json';
import * as fa from 'i18n/locales/achievements/fa.json';
import * as fr from 'i18n/locales/achievements/fr.json';
import * as hu from 'i18n/locales/achievements/hu.json';
import * as id_ID from 'i18n/locales/achievements/id_ID.json';
import * as ja from 'i18n/locales/achievements/ja.json';
import * as lt from 'i18n/locales/achievements/lt.json';
import * as lv from 'i18n/locales/achievements/lv.json';
import * as nl from 'i18n/locales/achievements/nl.json';
import * as no from 'i18n/locales/achievements/no.json';
import * as peo from 'i18n/locales/achievements/peo.json';
import * as pt from 'i18n/locales/achievements/pt.json';
import * as pt_BR from 'i18n/locales/achievements/pt_BR.json';
import * as ru from 'i18n/locales/achievements/ru.json';
import * as sl from 'i18n/locales/achievements/sl.json';
import * as sv from 'i18n/locales/achievements/sv.json';
import * as ta from 'i18n/locales/achievements/ta.json';
import * as tr_TR from 'i18n/locales/achievements/tr_TR.json';
import * as uk from 'i18n/locales/achievements/uk.json';
import * as vi from 'i18n/locales/achievements/vi.json';
import * as zh_CN from 'i18n/locales/achievements/zh_CN.json';
import * as zh_Hant from 'i18n/locales/achievements/zh_Hant.json';
import achievements from 'utils/data/achievements.json';
import { checkAchievements, newAchievements } from './condition';
const translations = {
ar,
arz,
az,
azb,
bn,
de_DE,
el,
en_GB,
en_US,
es,
es_419,
et,
fa,
fr,
hu,
id_ID,
ja,
lt,
lv,
nl,
no,
peo,
pt,
pt_BR,
ru,
sl,
sv,
ta,
tr_TR,
uk,
vi,
zh_CN,
zh_Hant,
};
const achievementLocaleLoaders = import.meta.glob('../../../i18n/locales/achievements/*.json');
const loadedAchievementTranslations = {};
// todo: clean this up and condition.js too
function getLocalisedAchievementData(id) {
const localised = translations[variables.languagecode][id] ||
translations.en_GB[id] || { name: id, description: '' };
async function loadAchievementTranslation(locale) {
if (loadedAchievementTranslations[locale]) {
return loadedAchievementTranslations[locale];
}
const path = `../../../i18n/locales/achievements/${locale}.json`;
const loader = achievementLocaleLoaders[path];
if (!loader) {
console.warn(`Achievement translation not found for: ${locale}, falling back to en_GB`);
return loadAchievementTranslation('en_GB');
}
const module = await loader();
const data = module.default;
loadedAchievementTranslations[locale] = data;
return data;
}
async function getLocalisedAchievementData(id) {
const locale = variables.languagecode;
let translations = loadedAchievementTranslations[locale];
if (!translations) {
translations = await loadAchievementTranslation(locale);
}
let localised = translations[id];
if (!localised && locale !== 'en_GB') {
const fallbackTranslations = loadedAchievementTranslations.en_GB || await loadAchievementTranslation('en_GB');
localised = fallbackTranslations[id];
}
if (!localised) {
return { name: id, description: '' };
}
return { name: localised.name, description: localised.description };
}

View File

@@ -9,9 +9,9 @@ export default class Stats {
}
const newAchievement = newAchievements(stats);
newAchievement.forEach((achievement) => {
for (const achievement of newAchievement) {
if (achievement) {
const { name } = getLocalisedAchievementData(achievement.id);
const { name } = await getLocalisedAchievementData(achievement.id);
toast.info(
`🏆 ${variables.getMessage('modals.main.settings.sections.stats.achievement_unlocked', { name: name })}`,
{
@@ -20,7 +20,7 @@ export default class Stats {
},
);
}
});
}
}
/**

View File

@@ -62,7 +62,13 @@ const Stats = () => {
};
const AchievementElement = ({ id, achieved, timestamp }) => {
const { name, description } = getLocalisedAchievementData(id);
const [achievementData, setAchievementData] = useState({ name: '', description: '' });
useEffect(() => {
getLocalisedAchievementData(id).then((data) => {
setAchievementData(data);
});
}, [id]);
return (
<div className="achievement">
@@ -73,8 +79,8 @@ const Stats = () => {
<MdAccessTime /> {new Date(timestamp).toLocaleDateString()}
</span>
)}
<span className="achievementTitle">{name}</span>
<span className="subtitle">{achieved ? description : '?????'}</span>
<span className="achievementTitle">{achievementData.name}</span>
<span className="subtitle">{achieved ? achievementData.description : '?????'}</span>
</div>
</div>
);

View File

@@ -99,10 +99,6 @@
"name": "Português (Brasil)",
"value": "pt_BR"
},
{
"name": "Português (Portugal)",
"value": "pt_PT"
},
{
"name": "Slovenščina",
"value": "sl"

View File

@@ -1,6 +1,21 @@
import variables from 'config/variables';
import ExperimentalInit from 'utils/experimental';
function loadGoogleFontAsync(fontFamily, id) {
const fontUrl = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(/ /g, '+')}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap`;
const existingLink = document.getElementById(id);
if (existingLink) {
existingLink.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = fontUrl;
link.id = id;
document.head.appendChild(link);
}
/**
* It loads the settings from localStorage and applies them to the page.
* @param hotreload - boolean
@@ -100,14 +115,17 @@ export function loadSettings(hotreload) {
const widgetFontWeight = localStorage.getItem('widgetFontWeight') || '400';
const widgetFontStyle = localStorage.getItem('widgetFontStyle') || 'normal';
const fontFamily = widgetFont.replace(/ /g, '+');
const googleFontUrl = `@import url('https://fonts.googleapis.com/css2?family=${fontFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');`;
loadGoogleFontAsync(widgetFont, 'customwidgetfont-link');
const existingStyle = document.getElementById('customwidgetfont');
if (existingStyle) {
existingStyle.remove();
}
document.head.insertAdjacentHTML(
'beforeend',
`
<style id='customwidgetfont'>
${googleFontUrl}
.greeting, .clock, .quote, .quoteauthor, .date, .weather, .navbar, #center {
font-family: '${widgetFont}', 'Lexend Deca', 'Inter', sans-serif !important;
font-weight: ${widgetFontWeight};
@@ -123,14 +141,17 @@ export function loadSettings(hotreload) {
const settingsFontWeight = localStorage.getItem('settingsFontWeight') || '400';
const settingsFontStyle = localStorage.getItem('settingsFontStyle') || 'normal';
const fontFamily = settingsFont.replace(/ /g, '+');
const googleFontUrl = `@import url('https://fonts.googleapis.com/css2?family=${fontFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');`;
loadGoogleFontAsync(settingsFont, 'customsettingsfont-link');
const existingStyle = document.getElementById('customsettingsfont');
if (existingStyle) {
existingStyle.remove();
}
document.head.insertAdjacentHTML(
'beforeend',
`
<style id='customsettingsfont'>
${googleFontUrl}
.modal-content, .settings, .preferences, .marketplace, .welcome {
font-family: '${settingsFont}', 'Lexend Deca', 'Inter', sans-serif !important;
font-weight: ${settingsFontWeight};
@@ -148,14 +169,11 @@ export function loadSettings(hotreload) {
const greetingColor = localStorage.getItem('greetingColor');
if (greetingFont || greetingFontWeight || greetingFontStyle || greetingColor) {
let styleContent = '';
if (greetingFont) {
const fontFamily = greetingFont.replace(/ /g, '+');
styleContent += `@import url('https://fonts.googleapis.com/css2?family=${fontFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');\n`;
loadGoogleFontAsync(greetingFont, 'customgreetingfont-link');
}
styleContent += '.greeting {';
let styleContent = '.greeting {';
if (greetingFont) {
styleContent += `font-family: '${greetingFont}', 'Lexend Deca', 'Inter', sans-serif !important;`;
}
@@ -170,6 +188,11 @@ export function loadSettings(hotreload) {
}
styleContent += '}';
const existingStyle = document.getElementById('customgreetingfont');
if (existingStyle) {
existingStyle.remove();
}
document.head.insertAdjacentHTML(
'beforeend',
`<style id='customgreetingfont'>${styleContent}</style>`,
@@ -182,14 +205,11 @@ export function loadSettings(hotreload) {
const clockColor = localStorage.getItem('clockColor');
if (clockFont || clockFontWeight || clockFontStyle || clockColor) {
let styleContent = '';
if (clockFont) {
const fontFamily = clockFont.replace(/ /g, '+');
styleContent += `@import url('https://fonts.googleapis.com/css2?family=${fontFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');\n`;
loadGoogleFontAsync(clockFont, 'customclockfont-link');
}
styleContent += '.clock, .date {';
let styleContent = '.clock, .date {';
if (clockFont) {
styleContent += `font-family: '${clockFont}', 'Lexend Deca', 'Inter', sans-serif !important;`;
}
@@ -204,6 +224,11 @@ export function loadSettings(hotreload) {
}
styleContent += '}';
const existingStyle = document.getElementById('customclockfont');
if (existingStyle) {
existingStyle.remove();
}
document.head.insertAdjacentHTML(
'beforeend',
`<style id='customclockfont'>${styleContent}</style>`,
@@ -216,14 +241,11 @@ export function loadSettings(hotreload) {
const quoteColor = localStorage.getItem('quoteColor');
if (quoteFont || quoteFontWeight || quoteFontStyle || quoteColor) {
let styleContent = '';
if (quoteFont) {
const fontFamily = quoteFont.replace(/ /g, '+');
styleContent += `@import url('https://fonts.googleapis.com/css2?family=${fontFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap');\n`;
loadGoogleFontAsync(quoteFont, 'customquotefont-link');
}
styleContent += '.quote, .quoteauthor {';
let styleContent = '.quote, .quoteauthor {';
if (quoteFont) {
styleContent += `font-family: '${quoteFont}', 'Lexend Deca', 'Inter', sans-serif !important;`;
}
@@ -238,6 +260,11 @@ export function loadSettings(hotreload) {
}
styleContent += '}';
const existingStyle = document.getElementById('customquotefont');
if (existingStyle) {
existingStyle.remove();
}
document.head.insertAdjacentHTML(
'beforeend',
`<style id='customquotefont'>${styleContent}</style>`,

View File

@@ -123,7 +123,13 @@ export default defineConfig(({ command, mode }) => {
sourcemap: !isProd,
rollupOptions: {
output: {
manualChunks: undefined,
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-modal'],
'vendor-icons': ['react-icons'],
'vendor-dnd': ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
'vendor-ui': ['react-toastify', '@floating-ui/react-dom'],
'i18n': ['@eartharoid/i18n'],
},
},
},
},