project-nomad/admin/inertia/components/CategoryCard.tsx
Martin Seener 134d1642af
Added initial i18n framework and most german translations
- Add i18next, react-i18next, i18next-browser-languagedetector packages
- Configure i18n initialization with language detector in lib/i18n.ts
- Created en/de translation files and moved most hard-coded strings into the files and translated them
- Uses locale-aware date formatting where applicable
- Added language-specific Wikipedia content files (wikipedia.en.json, wikipedia.de.json) and updated download URLs
- Added NOMAD_REPO_URL env variable for fork-friendly URL resolution (easier testing and rollout independent of Crosstalk repo)
2026-03-24 13:21:31 +01:00

99 lines
3.7 KiB
TypeScript

import { formatBytes } from '~/lib/util'
import { useTranslation } from 'react-i18next'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import type { CategoryWithStatus, SpecTier } from '../../types/collections'
import classNames from 'classnames'
import { IconChevronRight, IconCircleCheck } from '@tabler/icons-react'
export interface CategoryCardProps {
category: CategoryWithStatus
selectedTier?: SpecTier | null
onClick?: (category: CategoryWithStatus) => void
}
const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onClick }) => {
const { t } = useTranslation()
// Calculate total size range across all tiers
const getTierTotalSize = (tier: SpecTier, allTiers: SpecTier[]): number => {
let total = tier.resources.reduce((acc, r) => acc + r.size_mb * 1024 * 1024, 0)
// Add included tier sizes recursively
if (tier.includesTier) {
const includedTier = allTiers.find(t => t.slug === tier.includesTier)
if (includedTier) {
total += getTierTotalSize(includedTier, allTiers)
}
}
return total
}
const minSize = getTierTotalSize(category.tiers[0], category.tiers)
const maxSize = getTierTotalSize(category.tiers[category.tiers.length - 1], category.tiers)
// Determine which tier to highlight: selectedTier (wizard) > installedTierSlug (persisted)
const highlightedTierSlug = selectedTier?.slug || category.installedTierSlug
return (
<div
className={classNames(
'flex flex-col bg-desert-green rounded-lg p-6 text-white border shadow-sm hover:shadow-lg transition-shadow cursor-pointer h-80',
selectedTier ? 'border-lime-400 border-2' : 'border-desert-green'
)}
onClick={() => onClick?.(category)}
>
<div className="flex items-center mb-4">
<div className="flex justify-between w-full items-center">
<div className="flex items-center">
<DynamicIcon icon={category.icon as DynamicIconName} className="w-6 h-6 mr-2" />
<h3 className="text-lg font-semibold">{category.name}</h3>
</div>
{selectedTier ? (
<div className="flex items-center">
<IconCircleCheck className="w-5 h-5 text-lime-400" />
<span className="text-lime-400 text-sm ml-1">{selectedTier.name}</span>
</div>
) : (
<IconChevronRight className="w-5 h-5 text-white opacity-70" />
)}
</div>
</div>
<p className="text-gray-200 grow">{category.description}</p>
<div className="mt-4 pt-4 border-t border-white/20">
<p className="text-sm text-gray-300 mb-2">
{t('contentExplorer.categoryCard.tiersAvailable', { count: category.tiers.length })}
{!highlightedTierSlug && (
<span className="text-gray-400"> - {t('contentExplorer.categoryCard.clickToChoose')}</span>
)}
</p>
<div className="flex flex-wrap gap-2">
{category.tiers.map((tier) => {
const isInstalled = tier.slug === highlightedTierSlug
return (
<span
key={tier.slug}
className={classNames(
'text-xs px-2 py-1 rounded',
isInstalled
? 'bg-lime-500/30 text-lime-200'
: 'bg-white/10 text-gray-300',
selectedTier?.slug === tier.slug && 'ring-2 ring-lime-400'
)}
>
{tier.name}
</span>
)
})}
</div>
<p className="text-gray-300 text-xs mt-3">
{t('contentExplorer.categoryCard.size', { min: formatBytes(minSize, 1), max: formatBytes(maxSize, 1) })}
</p>
</div>
</div>
)
}
export default CategoryCard