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'],
+ },
},
},
},