This commit is contained in:
Alexander Medvedev 2026-08-27 18:49:20 +02:00
parent 669b33baef
commit b280372eb7
9 changed files with 271 additions and 23 deletions

View File

@ -8,19 +8,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Spellcheck (typos)
uses: crate-ci/typos@v1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v7 uses: actions/setup-node@v4
with: with:
node-version: 26 node-version: 22
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies
run: npm install --legacy-peer-deps run: npm install --legacy-peer-deps
- name: Validate i18n Translations
run: npm run check:i18n
- name: Typecheck
run: npm run typecheck
- name: Build Project - name: Build Project
# TODO: Fix all lints and replace with "build" run: npm run build
run: npm run buildWithoutLints
- name: Deploy Static Files to VPS - name: Deploy Static Files to VPS
uses: appleboy/scp-action@v1.0.0 uses: appleboy/scp-action@v1.0.0
with: with:

26
_typos.toml Normal file
View File

@ -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"

View File

@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"buildWithoutLints": "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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

158
scripts/validate-i18n.mjs Normal file
View File

@ -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);
}

11
src/@types/i18next.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
import 'i18next';
import type enTranslation from '../locales/en.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: typeof enTranslation;
};
}
}

View File

@ -307,9 +307,8 @@ const Navbar = ({ user }: any) => {
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}> <div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
<div className="nav-links"> <div className="nav-links">
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
{user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && ( {user && (user.plugin_count > 0 || user.role === 'admin' || user.role === 'moderator') && (
<NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('dashboard')}</NavLink> <NavLink to="/dashboard" onClick={() => setIsMenuOpen(false)}>{t('nav.dashboard')}</NavLink>
)} )}
{user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>} {user && <NavLink to="/dashboard/add-plugin" onClick={() => setIsMenuOpen(false)}>Publish</NavLink>}
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>} {user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
@ -321,7 +320,7 @@ const Navbar = ({ user }: any) => {
<Search size={16} className="nav-search-icon" /> <Search size={16} className="nav-search-icon" />
<input <input
type="text" type="text"
placeholder={t('search')} placeholder={t('nav.search_placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleSearch} onKeyDown={handleSearch}
@ -334,8 +333,8 @@ const Navbar = ({ user }: any) => {
<NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink> <NavLink to="/settings" className="nav-user-link" onClick={() => setIsMenuOpen(false)}>{user.username}</NavLink>
) : ( ) : (
<> <>
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('login')}</Link> <Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('nav.login')}</Link>
<Link to="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('register')}</Link> <Link to="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('nav.register')}</Link>
</> </>
)} )}
</div> </div>

View File

@ -38,31 +38,35 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
const timerRef = useRef<any>(null); const timerRef = useRef<any>(null);
const cycleRef = useRef<any>(null); const cycleRef = useRef<any>(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 { try {
const data = JSON.parse(translatedStr); 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.'; return data[currentLang] || data.en || Object.values(data)[0] || 'No description available.';
} catch { } catch {
return 'No description available.'; return 'No description available.';
} }
}; };
const priceInfo = useMemo(() => getEffectivePrice(plugin), [plugin]); const priceInfo = useMemo(() => (plugin ? getEffectivePrice(plugin) : { display: '€0.00', isSale: false, originalDisplay: '' }), [plugin]);
const isSale = plugin.type === 'paid' && priceInfo.isSale; const isSale = plugin?.type === 'paid' && priceInfo.isSale;
const desc = getDescription(plugin.translated_descriptions); const desc = getDescription(plugin?.translated_descriptions);
const initial = plugin.name.charAt(0).toUpperCase(); const initial = (plugin?.name || '?').charAt(0).toUpperCase();
const slideshowImages = useMemo(() => { const slideshowImages = useMemo(() => {
if (!plugin) return [];
const images = []; const images = [];
if (plugin.preview_path) images.push(plugin.preview_path); if (plugin.preview_path) images.push(plugin.preview_path);
if (plugin.screenshots && plugin.screenshots.length > 0) { if (plugin.screenshots && plugin.screenshots.length > 0) {
images.push(...plugin.screenshots); images.push(...plugin.screenshots);
} }
return images; return images;
}, [plugin.preview_path, plugin.screenshots]); }, [plugin?.preview_path, plugin?.screenshots]);
if (!plugin) return null;
const startHover = () => { const startHover = () => {
setIsHovering(true); setIsHovering(true);

View File

@ -138,7 +138,7 @@ const formatDate = (dateStr: string) => {
}; };
const PluginDetail = () => { const PluginDetail = () => {
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { id: rawId, slug } = useParams(); const { id: rawId, slug } = useParams();
const id = rawId ? rawId.split('-')[0] : rawId; const id = rawId ? rawId.split('-')[0] : rawId;
const { user } = useAuth(); const { user } = useAuth();
@ -146,6 +146,8 @@ const PluginDetail = () => {
const location = useLocation(); const location = useLocation();
const [plugin, setPlugin] = useState<any>(null); const [plugin, setPlugin] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' }); const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
const [adShown, setAdShown] = useState(false); const [adShown, setAdShown] = useState(false);
const [mainScreenshot, setMainScreenshot] = useState<string | null>(null); const [mainScreenshot, setMainScreenshot] = useState<string | null>(null);
@ -230,6 +232,8 @@ const PluginDetail = () => {
// ── Fetch plugin ───────────────────────────────────────────────────────── // ── Fetch plugin ─────────────────────────────────────────────────────────
const fetchPlugin = () => { const fetchPlugin = () => {
setLoading(true);
setError(null);
api.get(`/plugins/${id}`).then(res => { api.get(`/plugins/${id}`).then(res => {
setPlugin(res.data); setPlugin(res.data);
if (res.data.youtube_video_url) { if (res.data.youtube_video_url) {
@ -246,6 +250,13 @@ const PluginDetail = () => {
window.history.replaceState({}, '', targetPath + location.search); 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)`; return `(${bytes} B)`;
}; };
if (loading) {
return (
<div className="container">
<div className="loading-state" style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
<p>{t('common.loading')}</p>
</div>
</div>
);
}
if (error || !plugin) {
return (
<div className="container">
<div className="empty-state" style={{ minHeight: '60vh', textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Plugin Not Found</h2>
<p style={{ color: 'var(--text-muted)', margin: '1rem 0 1.5rem' }}>
{error || 'The requested plugin could not be found.'}
</p>
<Link to="/" className="btn btn-secondary">
{t('common.back')}
</Link>
</div>
</div>
);
}
const downloadLabel = (() => { 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 (plugin.type === 'paid') {
if (!ownershipChecked) return 'Loading…'; if (!ownershipChecked) return 'Loading…';

View File

@ -464,7 +464,7 @@ const LoginPage = () => {
<button className="submit-btn" type="submit" disabled={isSubmitting}> <button className="submit-btn" type="submit" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<><span className="spinner" />{t('loading')}</> <><span className="spinner" />{t('common.loading')}</>
) : requires2fa ? ( ) : requires2fa ? (
'Verify' 'Verify'
) : ( ) : (
@ -476,7 +476,7 @@ const LoginPage = () => {
<div className="divider" /> <div className="divider" />
<div className="card-footer"> <div className="card-footer">
Don't have an account? <Link to="/register">Create one</Link> {t('auth.no_account')} <Link to="/register">{t('auth.create_account')}</Link>
</div> </div>
</div> </div>
</div> </div>