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
|
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
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",
|
"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
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-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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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…';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user