feat: Add language support (i18n)

This commit is contained in:
XlebyllleK
2025-01-12 17:12:43 +02:00
committed by GitHub
parent fc5b5ba217
commit b21d623aa0
17 changed files with 254 additions and 6992 deletions

4051
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,11 @@
"gh-pages": "^6.3.0",
"lucide-react": "^0.471.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"i18next": "^24.2.1",
"react-i18next": "^15.4.0",
"i18next-http-backend": "^3.0.1",
"i18next-browser-languagedetector": "^8.0.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

2776
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,10 @@ import {
Repository
} from './types';
import {
translate
} from './i18n';
export default function App(): JSX.Element {
const { packages, loading, error } = usePackages();
const [search, setSearch] = useState('');
@@ -47,6 +51,8 @@ export default function App(): JSX.Element {
setSelectedRepository(repo);
};
const count = filteredPackages.length;
return (
<div className="min-h-screen bg-nord-6 dark:bg-nord-0 transition-colors" role="main">
<Header onRepositoryChange={handleRepositoryFilterChange} />
@@ -59,26 +65,30 @@ export default function App(): JSX.Element {
{/* Package Counter */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-nord-3 dark:text-nord-4" aria-live="polite">
Showing {filteredPackages.length} package{filteredPackages.length !== 1 ? 's' : ''}
</p>
<p className="text-sm text-nord-3 dark:text-nord-4" aria-live="polite">{(count === 1) ? translate("App.package_count.single", {
count
}) : (count >= 2) && (count <= 4) ? translate("App.package_count.multiple", {
count
}) : translate("App.package_count.plural", {
count
})}</p>
</div>
{/* Error State */}
{error ? (
<div className="rounded-lg bg-nord-11/10 dark:bg-nord-11/20 p-4 text-nord-11" role="alert">
<p>An error occurred while fetching packages: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 inline-block text-sm text-nord-10 hover:underline"
>
Retry
</button>
<p>{translate("App.error.fetching-packages", {
error
})}</p>
<button onClick={
() => window.location.reload()
} className="mt-2 inline-block text-sm text-nord-10 hover:underline">{translate("App.error.retry-fetching-packages")}</button>
</div>
) : filteredPackages.length === 0 ? (
// Empty State
<div className="text-center text-nord-3 dark:text-nord-4 mt-12">
<p>No packages found matching your search.</p>
<p>{translate("App.no-packages-found")}</p>
</div>
) : (
// Package List

View File

@@ -1,18 +1,31 @@
import {
JSX
JSX,
useEffect
} from 'react';
import { ThemeToggle } from './ThemeToggle';
import { Logo } from './Logo';
import {
ThemeToggle
} from './ThemeToggle';
import {
Repository
Logo
} from './Logo';
import {
Repository,
Languages
} from '../types';
import {
MIRRORS
} from '../services/api';
import {
translate
} from '../i18n';
import i18next from 'i18next';
interface HeaderProps {
onRepositoryChange: (repo: Repository) => void;
}
@@ -23,12 +36,24 @@ export function Header({
const usedRepositories = new Set(Object.values(MIRRORS).map(mirror => mirror.repository));
const filteredRepository = Object.keys(Repository).reduce((acc, key) => {
// This code is flawed because it explicitly checks for 'ALL', reducing flexibility.
// A more generic approach should be used to filter unused repositories while preserving 'ALL'.
if ((key === 'ALL') || usedRepositories.has(Repository[key as keyof typeof Repository])) acc[key] = Repository[key as keyof typeof Repository];
return acc;
}, {} as Record<string, string>);
const handleLanguageChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const language = event.target.value;
i18next.changeLanguage(language).then(() => {
localStorage.setItem('selectedLanguage', language);
window.location.reload();
});
};
useEffect(() => {
const savedLanguage = localStorage.getItem('selectedLanguage');
if (savedLanguage && (savedLanguage !== i18next.language)) i18next.changeLanguage(savedLanguage);
}, []);
return (
<header className="bg-gradient-to-r from-nord-9 to-nord-8 via-nord-10 text-nord-6 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
@@ -46,7 +71,18 @@ export function Header({
(e) => onRepositoryChange(e.target.value as Repository)
} defaultValue="all" className="bg-nord-5 dark:bg-nord-1 text-black dark:text-white border-2 border-nord-4 dark:border-nord-2 rounded-lg py-2 px-4 focus:ring-2 focus:ring-nord-8">
{Object.values(filteredRepository).map((repository) => (
<option key={repository} value={repository}>{((repository === Repository.ALL) ? 'All Repositories' : repository.charAt(0).toUpperCase() + repository.slice(1))}</option>
<option key={repository} value={repository}>{((repository === Repository.ALL) ? translate("Header.all-repositories") : repository.charAt(0).toUpperCase() + repository.slice(1))}</option>
))}
</select>
</div>
{/* Language Dropdown */}
<div>
<select onChange={handleLanguageChange} defaultValue={i18next.language} className="bg-nord-5 dark:bg-nord-1 text-black dark:text-white border-2 border-nord-4 dark:border-nord-2 rounded-lg py-2 px-4 focus:ring-2 focus:ring-nord-8">
{Object.entries(Languages).map(([key, label]) => (
<option key={key} value={key.toLowerCase()}>
{label}
</option>
))}
</select>
</div>

View File

@@ -5,6 +5,10 @@ import {
import { Terminal, Copy, Check } from 'lucide-react';
import {
translate
} from '../i18n';
interface InstallGuideProps {
packageName: string;
}
@@ -25,20 +29,13 @@ export function InstallGuide({ packageName }: InstallGuideProps): JSX.Element {
{/* Title Section */}
<div className="flex items-center gap-3 mb-4">
<Terminal className="h-5 w-5 text-nord-9 dark:text-nord-7" />
<span className="text-nord-2 dark:text-nord-5 text-sm font-semibold uppercase tracking-wide">
Installation Command
</span>
<span className="text-nord-2 dark:text-nord-5 text-sm font-semibold uppercase tracking-wide">{translate("InstallGuide.installation-command")}</span>
</div>
{/* Command Display Section */}
<div className="flex items-center justify-between bg-nord-4 dark:bg-nord-2 rounded-lg px-5 py-3">
<code className="text-nord-8 dark:text-nord-6 font-mono text-sm truncate">{command}</code>
<button
onClick={copyCommand}
className="flex items-center justify-center w-8 h-8 rounded-full bg-nord-3 hover:bg-nord-7 dark:bg-nord-5 dark:hover:bg-nord-8 transition-colors"
title={copied ? 'Copied!' : 'Copy to clipboard'}
aria-label={copied ? 'Copied' : 'Copy command'}
>
<button onClick={copyCommand} className="flex items-center justify-center w-8 h-8 rounded-full bg-nord-3 hover:bg-nord-7 dark:bg-nord-5 dark:hover:bg-nord-8 transition-colors" title={copied ? translate("InstallGuide.copy.success") : translate("InstallGuide.copy.prompt")}>
{copied ? (
<Check className="h-4 w-4 text-nord-6 dark:text-nord-0" />
) : (

View File

@@ -2,6 +2,10 @@ import {
JSX
} from 'react';
import {
translate
} from '../i18n';
export function Logo(): JSX.Element {
return (
<div className="flex items-center gap-3">
@@ -22,10 +26,8 @@ export function Logo(): JSX.Element {
</svg>
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Snigdha OS Package List</h1>
<p className="text-nord-4 text-sm mt-1">
Browse and search through the official Snigdha OS package repository
</p>
<h1 className="text-2xl font-bold tracking-tight">{translate("Logo.title")}</h1>
<p className="text-nord-4 text-sm mt-1">{translate("Logo.description")}</p>
</div>
</div>
);

View File

@@ -7,6 +7,10 @@ import { Box, ChevronDown, ChevronUp } from 'lucide-react';
import { Package } from '../types';
import { InstallGuide } from './InstallGuide';
import {
translate
} from '../i18n';
interface PackageCardProps {
package: Package;
}
@@ -49,12 +53,12 @@ export function PackageCard({ package: pkg }: PackageCardProps): JSX.Element {
{expanded ? (
<>
<ChevronUp className="h-5 w-5 animate-bounce" />
Hide Installation
{translate("PackageCard.hide-installation")}
</>
) : (
<>
<ChevronDown className="h-5 w-5 animate-bounce" />
Show Installation
{translate("PackageCard.show-installation")}
</>
)}
</button>

View File

@@ -1,38 +0,0 @@
import React, {
JSX
} from 'react';
interface BadgeProps {
children: React.ReactNode;
color?: 'default' | 'success' | 'warning' | 'error'; // Add more colors as needed
size?: 'small' | 'medium' | 'large'; // Size options
ariaLabel?: string; // For accessibility
}
const colorClasses = {
default: 'bg-gradient-to-r from-nord-7 to-nord-8/80 dark:from-nord-8/50 dark:to-nord-9/80 text-nord-0 dark:text-nord-6',
success: 'bg-green-500 text-white',
warning: 'bg-yellow-500 text-black',
error: 'bg-red-500 text-white',
};
const sizeClasses = {
small: 'text-xs px-2 py-1',
medium: 'text-sm px-3 py-1.5',
large: 'text-base px-4 py-2',
};
export function Badge({ children, color = 'default', size = 'medium', ariaLabel }: BadgeProps): JSX.Element {
const badgeColorClass = colorClasses[color] || colorClasses.default;
const badgeSizeClass = sizeClasses[size] || sizeClasses.medium;
return (
<span
className={`inline-flex items-center rounded-full font-semibold shadow-md hover:shadow-lg transition-all duration-300 ${badgeColorClass} ${badgeSizeClass}`}
aria-label={ariaLabel}
title={ariaLabel} // Optional: Add title for tooltips
>
{children}
</span>
);
}

View File

@@ -1,28 +0,0 @@
import {
JSX
} from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
interface ExpandButtonProps {
expanded: boolean;
onClick: () => void;
}
export function ExpandButton({ expanded, onClick }: ExpandButtonProps): JSX.Element {
return (
<button
onClick={onClick}
className="flex items-center gap-2 px-3 py-1 text-sm font-medium text-nord-9 dark:text-nord-8 hover:text-nord-10 dark:hover:text-nord-7 focus:outline-none focus:ring-2 focus:ring-nord-8 dark:focus:ring-nord-9 rounded-lg transition-all duration-300"
>
<span
className={`transform transition-transform duration-300 ${
expanded ? 'rotate-180' : ''
}`}
>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</span>
{expanded ? 'Hide installation' : 'Show installation'}
</button>
);
}

View File

@@ -1,59 +0,0 @@
import {
useState,
JSX
} from 'react';
import { Boxes } from 'lucide-react';
import { Package } from '../../types';
import { InstallGuide } from '../InstallGuide';
import { Badge } from './Badge';
import { ExpandButton } from './ExpandButton';
interface PackageCardProps {
package: Package;
}
export function PackageCard({ package: pkg }: PackageCardProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
return (
<div className="group bg-nord-5 dark:bg-nord-1 rounded-xl shadow-lg border border-nord-4 dark:border-nord-2 hover:shadow-xl transition-all duration-300 overflow-hidden">
{/* Header Section */}
<div className="p-5">
<div className="flex items-start gap-4">
{/* Icon */}
<div className="p-3 bg-nord-7/10 dark:bg-nord-7/20 rounded-full shadow-md group-hover:scale-105 transition-transform">
<Boxes className="h-6 w-6 text-nord-7" />
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg text-nord-0 dark:text-nord-6 group-hover:text-nord-8 transition-colors">
{pkg.name}
</h3>
<span className="text-sm text-nord-3 dark:text-nord-4">{pkg.version}</span>
</div>
<p className="mt-2 text-sm text-nord-2 dark:text-nord-4 line-clamp-2">
{pkg.description}
</p>
<div className="mt-3 flex items-center justify-between">
<Badge>{pkg.repository}</Badge>
<ExpandButton
expanded={expanded}
onClick={() => setExpanded(!expanded)}
aria-label={expanded ? 'Collapse package details' : 'Expand package details'}
/>
</div>
</div>
</div>
</div>
{/* Expandable Section */}
{expanded && (
<div className="border-t border-nord-4 dark:border-nord-2 bg-nord-6 dark:bg-nord-0 p-5 transition-opacity duration-300">
<InstallGuide packageName={pkg.name} />
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,10 @@ import {
import { Search } from 'lucide-react';
import {
translate
} from '../i18n';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
@@ -26,7 +30,7 @@ export function SearchBar({
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search packages..."
placeholder={translate("SearchBar.placeholder")}
className="block w-full pl-10 pr-4 py-3 border-2 border-nord-4 dark:border-nord-2 rounded-xl bg-nord-5 dark:bg-nord-1 focus:ring-2 focus:ring-nord-8 focus:border-transparent text-nord-0 dark:text-nord-6 placeholder-nord-3 dark:placeholder-nord-4 transition-all ease-in-out duration-200 shadow-md hover:shadow-lg"
/>

46
src/i18n.tsx Normal file
View File

@@ -0,0 +1,46 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import {
initReactI18next
} from "react-i18next";
import {
ENGLISH
} from './locales/en';
import {
UKRAINIAN
} from './locales/uk';
const resources = {
en: {
translation: ENGLISH
},
uk: {
translation: UKRAINIAN
}
};
i18n.use(initReactI18next).use(LanguageDetector).init({
resources,
fallbackLng: (localStorage.getItem('selectedLanguage') || "en"),
interpolation: {
escapeValue: false
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage']
}
});
export const translate = (key: string, options?: {
[key: string]: any
}): string => {
return i18n.t(key, options);
};
export default i18n;

49
src/locales/en.tsx Normal file
View File

@@ -0,0 +1,49 @@
export const ENGLISH = {
// src/components/Header.tsx
Header: {
"all-repositories": "All Repositories"
},
// src/components/InstallGuide.tsx
InstallGuide: {
"installation-command": "Installation Command",
"copy": {
"prompt": "Copy to clipboard",
"success": "Copied!"
}
},
// src/components/Logo.tsx
Logo: {
"title": "Snigdha OS Package List",
"description": "Browse and search through the official Snigdha OS package repository",
},
// src/components/PackageCard.tsx
PackageCard: {
"show-installation": "Show Installation",
"hide-installation": "Hide Installation"
},
// src/components/SearchBar.tsx
SearchBar: {
"placeholder": "Search packages..."
},
// src/App.tsx
App: {
"package_count": {
"single": "Showing {{count}} package",
"multiple": "Showing {{count}} packages",
"plural": "Showing {{count}} packages",
},
error: {
"fetching-packages": "An error occurred while fetching packages: {{error}}",
"retry-fetching-packages": "Retry"
},
"no-packages-found": "No packages found matching your search."
}
};

49
src/locales/uk.tsx Normal file
View File

@@ -0,0 +1,49 @@
export const UKRAINIAN = {
// src/components/Header.tsx
Header: {
"all-repositories": "Усі репозиторії"
},
// src/components/InstallGuide.tsx
InstallGuide: {
"installation-command": "Команда для встановлення",
"copy": {
"prompt": "Копіювати в буфер обміну",
"success": "Скопійовано!"
}
},
// src/components/Logo.tsx
Logo: {
"title": "Список пакетів Snigdha OS",
"description": "Переглядайте та шукайте в офіційному репозиторії пакетів Snigdha OS",
},
// src/components/PackageCard.tsx
PackageCard: {
"show-installation": "Показати інструкцію з встановлення",
"hide-installation": "Сховати інструкцію з встановлення"
},
// src/components/SearchBar.tsx
SearchBar: {
"placeholder": "Шукати пакети..."
},
// src/App.tsx
App: {
"package_count": {
"single": "Показано {{count}} пакет",
"multiple": "Показано {{count}} пакети",
"plural": "Показано {{count}} пакетів"
},
error: {
"fetching-packages": "Сталася помилка при отриманні пакетів: {{error}}",
"retry-fetching-packages": "Спробувати ще раз"
},
"no-packages-found": "Не знайдено жодного пакета, що відповідає вашому запиту."
}
};

View File

@@ -1,10 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import {
StrictMode
} from 'react';
import {
createRoot
} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import './i18n';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -10,6 +10,12 @@ export enum Repository {
// Type alias for UI themes
export type Theme = ('light' | 'dark');
// Enumeration of supported languages
export enum Languages {
EN = "English",
UK = "Українська"
}
// Interface representing a single package
export interface Package {
/** The name of the package */