From b280372eb7f534e7abfded100a0a81c73d4a2223 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Thu, 27 Aug 2026 18:49:20 +0200 Subject: [PATCH] fix: nav --- .github/workflows/deploy-backend.yml | 19 +++- _typos.toml | 26 +++++ package.json | 5 +- scripts/validate-i18n.mjs | 158 +++++++++++++++++++++++++++ src/@types/i18next.d.ts | 11 ++ src/App.tsx | 9 +- src/components/PluginCard.tsx | 20 ++-- src/pages/PluginDetail.tsx | 42 ++++++- src/pages/auth/LoginPage.tsx | 4 +- 9 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 _typos.toml create mode 100644 scripts/validate-i18n.mjs create mode 100644 src/@types/i18next.d.ts diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 729ef9c..456286b 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -8,19 +8,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v7 + uses: actions/checkout@v4 + + - name: Spellcheck (typos) + uses: crate-ci/typos@v1 - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@v4 with: - node-version: 26 + node-version: 22 cache: 'npm' - name: Install Dependencies run: npm install --legacy-peer-deps + + - name: Validate i18n Translations + run: npm run check:i18n + + - name: Typecheck + run: npm run typecheck + - name: Build Project - # TODO: Fix all lints and replace with "build" - run: npm run buildWithoutLints + run: npm run build - name: Deploy Static Files to VPS uses: appleboy/scp-action@v1.0.0 with: diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..4e525bd --- /dev/null +++ b/_typos.toml @@ -0,0 +1,26 @@ +[files] +extend-exclude = [ + "src/locales/de.json", + "src/locales/es.json", + "src/locales/fr.json", + "src/locales/it.json", + "src/locales/ja.json", + "src/locales/ko.json", + "src/locales/nl.json", + "src/locales/pl.json", + "src/locales/pt.json", + "src/locales/ru.json", + "src/locales/tr.json", + "src/locales/zh.json", + "dist/**", + "package-lock.json" +] + +[default.extend-words] +wasm = "wasm" +preorder = "preorder" +adwall = "adwall" +PumpkinMC = "PumpkinMC" +pumpkinmc = "pumpkinmc" +Mis = "Mis" +mis = "mis" diff --git a/package.json b/package.json index 8eb52e2..eb7670a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "buildWithoutLints": "vite build", - "lint": "eslint .", + "lint": "eslint . && npm run check:i18n", + "typecheck": "tsc -b", + "check:i18n": "node scripts/validate-i18n.mjs", + "test": "npm run check:i18n && npm run typecheck", "preview": "vite preview" }, "dependencies": { diff --git a/scripts/validate-i18n.mjs b/scripts/validate-i18n.mjs new file mode 100644 index 0000000..f2a5a0e --- /dev/null +++ b/scripts/validate-i18n.mjs @@ -0,0 +1,158 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.resolve(__dirname, '..'); +const localesDir = path.join(rootDir, 'src', 'locales'); +const srcDir = path.join(rootDir, 'src'); + +let hasErrors = false; + +function logError(msg) { + console.error(`\x1b[31m✖ ${msg}\x1b[0m`); + hasErrors = true; +} + +function logSuccess(msg) { + console.log(`\x1b[32m✔ ${msg}\x1b[0m`); +} + +function logInfo(msg) { + console.log(`\x1b[36mℹ ${msg}\x1b[0m`); +} + +// 1. Load and parse all locale files +logInfo('Validating locale JSON files...'); +const localeFiles = fs.readdirSync(localesDir).filter(f => f.endsWith('.json')); +const locales = {}; + +for (const file of localeFiles) { + const lang = path.basename(file, '.json'); + const filePath = path.join(localesDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + locales[lang] = JSON.parse(content); + } catch (err) { + logError(`Invalid JSON in ${file}: ${err.message}`); + } +} + +if (!locales.en) { + logError('Reference locale en.json not found!'); + process.exit(1); +} + +// Helper to flatten nested object keys +function flattenKeys(obj, prefix = '') { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + result[fullKey] = { type: 'object', value }; + Object.assign(result, flattenKeys(value, fullKey)); + } else { + result[fullKey] = { type: typeof value, value }; + } + } + return result; +} + +// Helper to get nested value by dot path +function getNestedValue(obj, keyPath) { + const parts = keyPath.split('.'); + let current = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return undefined; + } + } + return current; +} + +const enFlat = flattenKeys(locales.en); +const enLeafKeys = Object.keys(enFlat).filter(k => enFlat[k].type === 'string'); + +logSuccess(`Reference locale en.json has ${enLeafKeys.length} string keys.`); + +// 2. Check parity of all other locales against en.json +logInfo('Checking translation parity across all locales...'); +for (const [lang, data] of Object.entries(locales)) { + if (lang === 'en') continue; + const langFlat = flattenKeys(data); + + for (const key of enLeafKeys) { + if (!langFlat[key]) { + logError(`[${lang}.json] Missing translation key: "${key}"`); + } else if (langFlat[key].type !== 'string') { + logError(`[${lang}.json] Key "${key}" should be string, found ${langFlat[key].type}`); + } else if (typeof langFlat[key].value === 'string' && langFlat[key].value.trim() === '') { + logError(`[${lang}.json] Key "${key}" is empty string`); + } + } + + for (const key of Object.keys(langFlat)) { + if (langFlat[key].type === 'string' && !enFlat[key]) { + logError(`[${lang}.json] Extraneous translation key not in en.json: "${key}"`); + } + } +} + +// 3. Scan source code for t('...') usages +logInfo('Scanning source code for t(...) translation calls...'); +const tRegex = /\bt\(\s*['"`]([^'"`]+)['"`]/g; + +function scanDir(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'locales' || entry.name === 'node_modules' || entry.name === 'dist') { + continue; + } + scanDir(fullPath); + } else if (/\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + const lines = content.split('\n'); + const relPath = path.relative(rootDir, fullPath); + + lines.forEach((line, lineIndex) => { + // Strip single line comments + const cleanLine = line.replace(/\/\/.*$/, ''); + let match; + tRegex.lastIndex = 0; + while ((match = tRegex.exec(cleanLine)) !== null) { + const key = match[1]; + const lineNum = lineIndex + 1; + + // Check if key exists in en.json + const value = getNestedValue(locales.en, key); + + if (value === undefined) { + logError(`${relPath}:${lineNum} - Translation key "${key}" does not exist in en.json`); + } else if (typeof value === 'object' && value !== null) { + logError( + `${relPath}:${lineNum} - Translation key "${key}" returned an object instead of string! Did you mean a nested key like "${key}.${Object.keys(value)[0]}"?` + ); + } else if (typeof value !== 'string') { + logError(`${relPath}:${lineNum} - Translation key "${key}" is of type ${typeof value}, expected string`); + } + } + }); + } + } +} + +scanDir(srcDir); + +if (hasErrors) { + console.error('\n\x1b[31m❌ i18n validation failed with errors above.\x1b[0m\n'); + process.exit(1); +} else { + console.log('\n\x1b[32m✔ All translation keys and locale files validated successfully!\x1b[0m\n'); + process.exit(0); +} diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts new file mode 100644 index 0000000..f5e5941 --- /dev/null +++ b/src/@types/i18next.d.ts @@ -0,0 +1,11 @@ +import 'i18next'; +import type enTranslation from '../locales/en.json'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'translation'; + resources: { + translation: typeof enTranslation; + }; + } +} diff --git a/src/App.tsx b/src/App.tsx index 6757e0e..12e9745 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -307,9 +307,8 @@ const Navbar = ({ user }: any) => {
- setIsMenuOpen(false)}>{t('home')} {user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && ( - setIsMenuOpen(false)}>{t('dashboard')} + setIsMenuOpen(false)}>{t('nav.dashboard')} )} {user && setIsMenuOpen(false)}>Publish} {user && (user.role === 'admin' || user.role === 'moderator') && setIsMenuOpen(false)}>Staff} @@ -321,7 +320,7 @@ const Navbar = ({ user }: any) => { setSearchQuery(e.target.value)} onKeyDown={handleSearch} @@ -334,8 +333,8 @@ const Navbar = ({ user }: any) => { setIsMenuOpen(false)}>{user.username} ) : ( <> - setIsMenuOpen(false)}>{t('login')} - setIsMenuOpen(false)}>{t('register')} + setIsMenuOpen(false)}>{t('nav.login')} + setIsMenuOpen(false)}>{t('nav.register')} )}
diff --git a/src/components/PluginCard.tsx b/src/components/PluginCard.tsx index 63f6de6..40f3883 100644 --- a/src/components/PluginCard.tsx +++ b/src/components/PluginCard.tsx @@ -38,31 +38,35 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc const timerRef = useRef(null); const cycleRef = useRef(null); - const accent = useMemo(() => getAccentColor(plugin.name), [plugin.name]); + const accent = useMemo(() => getAccentColor(plugin?.name || ''), [plugin?.name]); - const getDescription = (translatedStr: string) => { + const getDescription = (translatedStr?: string) => { + if (!translatedStr) return 'No description available.'; try { const data = JSON.parse(translatedStr); - const currentLang = i18n.language.split('-')[0]; + const currentLang = (i18n.language || 'en').split('-')[0]; return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.'; } catch { return 'No description available.'; } }; - const priceInfo = useMemo(() => getEffectivePrice(plugin), [plugin]); - const isSale = plugin.type === 'paid' && priceInfo.isSale; - const desc = getDescription(plugin.translated_descriptions); - const initial = plugin.name.charAt(0).toUpperCase(); + const priceInfo = useMemo(() => (plugin ? getEffectivePrice(plugin) : { display: '€0.00', isSale: false, originalDisplay: '' }), [plugin]); + const isSale = plugin?.type === 'paid' && priceInfo.isSale; + const desc = getDescription(plugin?.translated_descriptions); + const initial = (plugin?.name || '?').charAt(0).toUpperCase(); const slideshowImages = useMemo(() => { + if (!plugin) return []; const images = []; if (plugin.preview_path) images.push(plugin.preview_path); if (plugin.screenshots && plugin.screenshots.length > 0) { images.push(...plugin.screenshots); } return images; - }, [plugin.preview_path, plugin.screenshots]); + }, [plugin?.preview_path, plugin?.screenshots]); + + if (!plugin) return null; const startHover = () => { setIsHovering(true); diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index c92bc7c..c41333d 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -138,7 +138,7 @@ const formatDate = (dateStr: string) => { }; const PluginDetail = () => { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation(); const { id: rawId, slug } = useParams(); const id = rawId ? rawId.split('-')[0] : rawId; const { user } = useAuth(); @@ -146,6 +146,8 @@ const PluginDetail = () => { const location = useLocation(); const [plugin, setPlugin] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' }); const [adShown, setAdShown] = useState(false); const [mainScreenshot, setMainScreenshot] = useState(null); @@ -230,6 +232,8 @@ const PluginDetail = () => { // ── Fetch plugin ───────────────────────────────────────────────────────── const fetchPlugin = () => { + setLoading(true); + setError(null); api.get(`/plugins/${id}`).then(res => { setPlugin(res.data); if (res.data.youtube_video_url) { @@ -246,6 +250,13 @@ const PluginDetail = () => { window.history.replaceState({}, '', targetPath + location.search); } } + }) + .catch(err => { + console.error('Failed to load plugin:', err); + setError('Plugin not found or failed to load.'); + }) + .finally(() => { + setLoading(false); }); }; @@ -490,8 +501,35 @@ const PluginDetail = () => { return `(${bytes} B)`; }; + if (loading) { + return ( +
+
+
+

{t('common.loading')}

+
+
+ ); + } + + if (error || !plugin) { + return ( +
+
+

Plugin Not Found

+

+ {error || 'The requested plugin could not be found.'} +

+ + {t('common.back')} + +
+
+ ); + } + const downloadLabel = (() => { - const sizeStr = plugin.file_size ? ` ${formatSize(plugin.file_size)}` : ''; + const sizeStr = plugin?.file_size ? ` ${formatSize(plugin.file_size)}` : ''; if (plugin.type === 'paid') { if (!ownershipChecked) return 'Loading…'; diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 8e6510b..02fe85d 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -464,7 +464,7 @@ const LoginPage = () => {