From 3c53d0322e79359d5c54c2515f19984d396a830c Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Fri, 6 Feb 2026 17:30:55 +0000 Subject: [PATCH] feat: implement lazy loading for components and add loading states for improved user experience --- .../misc/components/SettingsLoader.jsx | 56 +++++++ .../misc/components/SettingsLoader.scss | 158 ++++++++++++++++++ .../misc/components/WelcomeLoader.jsx | 19 +++ .../misc/components/WelcomeLoader.scss | 85 ++++++++++ src/features/misc/modals/Modals.jsx | 9 +- src/features/misc/views/Settings.jsx | 55 +++--- src/features/misc/views/Settings.scss | 13 ++ src/features/stats/api/achievements/index.js | 113 +++++-------- src/features/stats/api/stats.js | 6 +- src/features/stats/options/StatsOptions.jsx | 12 +- src/i18n/languages.json | 4 - src/utils/settings/load.js | 69 +++++--- vite.config.mjs | 8 +- 13 files changed, 474 insertions(+), 133 deletions(-) create mode 100644 src/features/misc/components/SettingsLoader.jsx create mode 100644 src/features/misc/components/SettingsLoader.scss create mode 100644 src/features/misc/components/WelcomeLoader.jsx create mode 100644 src/features/misc/components/WelcomeLoader.scss create mode 100644 src/features/misc/views/Settings.scss diff --git a/src/features/misc/components/SettingsLoader.jsx b/src/features/misc/components/SettingsLoader.jsx new file mode 100644 index 00000000..f2e4a945 --- /dev/null +++ b/src/features/misc/components/SettingsLoader.jsx @@ -0,0 +1,56 @@ +import './SettingsLoader.scss'; + +const SettingsLoader = () => { + return ( +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ ); +}; + +export default SettingsLoader; diff --git a/src/features/misc/components/SettingsLoader.scss b/src/features/misc/components/SettingsLoader.scss new file mode 100644 index 00000000..af2d9bf5 --- /dev/null +++ b/src/features/misc/components/SettingsLoader.scss @@ -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); + } + } +} diff --git a/src/features/misc/components/WelcomeLoader.jsx b/src/features/misc/components/WelcomeLoader.jsx new file mode 100644 index 00000000..c58ca8fc --- /dev/null +++ b/src/features/misc/components/WelcomeLoader.jsx @@ -0,0 +1,19 @@ +import './WelcomeLoader.scss'; + +const WelcomeLoader = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +}; + +export default WelcomeLoader; diff --git a/src/features/misc/components/WelcomeLoader.scss b/src/features/misc/components/WelcomeLoader.scss new file mode 100644 index 00000000..7393aba2 --- /dev/null +++ b/src/features/misc/components/WelcomeLoader.scss @@ -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; + } + } +} diff --git a/src/features/misc/modals/Modals.jsx b/src/features/misc/modals/Modals.jsx index b8b89557..7b1217bc 100644 --- a/src/features/misc/modals/Modals.jsx +++ b/src/features/misc/modals/Modals.jsx @@ -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} > - closeWelcome()} modalSkip={() => previewWelcome()} /> + }> + closeWelcome()} modalSkip={() => previewWelcome()} /> + {preview && window.location.reload()} />} diff --git a/src/features/misc/views/Settings.jsx b/src/features/misc/views/Settings.jsx index 63ceb54e..5fdd7f2c 100644 --- a/src/features/misc/views/Settings.jsx +++ b/src/features/misc/views/Settings.jsx @@ -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 }) => (
- + }> +
+ +
+
))} diff --git a/src/features/misc/views/Settings.scss b/src/features/misc/views/Settings.scss new file mode 100644 index 00000000..2668cbf5 --- /dev/null +++ b/src/features/misc/views/Settings.scss @@ -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; +} diff --git a/src/features/stats/api/achievements/index.js b/src/features/stats/api/achievements/index.js index 924b6ee0..c9d09169 100644 --- a/src/features/stats/api/achievements/index.js +++ b/src/features/stats/api/achievements/index.js @@ -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 }; } diff --git a/src/features/stats/api/stats.js b/src/features/stats/api/stats.js index 8e72ce56..cfb65762 100644 --- a/src/features/stats/api/stats.js +++ b/src/features/stats/api/stats.js @@ -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 { }, ); } - }); + } } /** diff --git a/src/features/stats/options/StatsOptions.jsx b/src/features/stats/options/StatsOptions.jsx index dc50548f..eda13ecb 100644 --- a/src/features/stats/options/StatsOptions.jsx +++ b/src/features/stats/options/StatsOptions.jsx @@ -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 (
@@ -73,8 +79,8 @@ const Stats = () => { {new Date(timestamp).toLocaleDateString()} )} - {name} - {achieved ? description : '?????'} + {achievementData.name} + {achieved ? achievementData.description : '?????'}
); diff --git a/src/i18n/languages.json b/src/i18n/languages.json index 8825d0b7..7fe4292a 100644 --- a/src/i18n/languages.json +++ b/src/i18n/languages.json @@ -99,10 +99,6 @@ "name": "Português (Brasil)", "value": "pt_BR" }, - { - "name": "Português (Portugal)", - "value": "pt_PT" - }, { "name": "Slovenščina", "value": "sl" diff --git a/src/utils/settings/load.js b/src/utils/settings/load.js index 09ca611e..a274d933 100644 --- a/src/utils/settings/load.js +++ b/src/utils/settings/load.js @@ -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', ` `, @@ -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', ``, @@ -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', ``, diff --git a/vite.config.mjs b/vite.config.mjs index 38372e10..c24f4b0b 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -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'], + }, }, }, },