fix: nav
This commit is contained in:
parent
669b33baef
commit
b280372eb7
19
.github/workflows/deploy-backend.yml
vendored
19
.github/workflows/deploy-backend.yml
vendored
@ -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:
|
||||
|
||||
26
_typos.toml
Normal file
26
_typos.toml
Normal 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"
|
||||
@ -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": {
|
||||
|
||||
158
scripts/validate-i18n.mjs
Normal file
158
scripts/validate-i18n.mjs
Normal 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
11
src/@types/i18next.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -307,9 +307,8 @@ const Navbar = ({ user }: any) => {
|
||||
|
||||
<div className={`nav-content ${isMenuOpen ? 'open' : ''}`}>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/" onClick={() => setIsMenuOpen(false)}>{t('home')}</NavLink>
|
||||
{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 && (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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search')}
|
||||
placeholder={t('nav.search_placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearch}
|
||||
@ -334,8 +333,8 @@ const Navbar = ({ user }: any) => {
|
||||
<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="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('register')}</Link>
|
||||
<Link to="/login" className="btn btn-secondary" onClick={() => setIsMenuOpen(false)}>{t('nav.login')}</Link>
|
||||
<Link to="/register" className="btn" onClick={() => setIsMenuOpen(false)}>{t('nav.register')}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -38,31 +38,35 @@ const PluginCard = ({ plugin, hideDescription = false }: { plugin: any; hideDesc
|
||||
const timerRef = 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 {
|
||||
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);
|
||||
|
||||
@ -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<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reviewForm, setReviewForm] = useState({ rating: 5, comment: '' });
|
||||
const [adShown, setAdShown] = useState(false);
|
||||
const [mainScreenshot, setMainScreenshot] = useState<string | null>(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 (
|
||||
<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 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…';
|
||||
|
||||
@ -464,7 +464,7 @@ const LoginPage = () => {
|
||||
|
||||
<button className="submit-btn" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<><span className="spinner" />{t('loading')}</>
|
||||
<><span className="spinner" />{t('common.loading')}</>
|
||||
) : requires2fa ? (
|
||||
'Verify'
|
||||
) : (
|
||||
@ -476,7 +476,7 @@ const LoginPage = () => {
|
||||
<div className="divider" />
|
||||
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user