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)
This commit is contained in:
Martin Seener 2026-03-24 13:21:31 +01:00
parent efe6af9b24
commit 134d1642af
No known key found for this signature in database
GPG Key ID: A9B09D21F948EF59
63 changed files with 2795 additions and 881 deletions

View File

@ -15,4 +15,6 @@ REDIS_PORT=6379
# Storage path for NOMAD content (ZIM files, maps, etc.)
# On Windows dev, use an absolute path like: C:/nomad-storage
# On Linux production, use: /opt/project-nomad/storage
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
NOMAD_STORAGE_PATH=/opt/project-nomad/storage
# Upstream GitHub repository URL (override for forks)
# NOMAD_REPO_URL=https://github.com/Crosstalk-Solutions/project-nomad

View File

@ -77,12 +77,14 @@ export default class ZimController {
// Wikipedia selector endpoints
async getWikipediaState({}: HttpContext) {
return this.zimService.getWikipediaState()
async getWikipediaState({ request }: HttpContext) {
const locale = request.input('locale', 'en')
return this.zimService.getWikipediaState(locale)
}
async selectWikipedia({ request }: HttpContext) {
const payload = await request.validateUsing(selectWikipediaValidator)
return this.zimService.selectWikipedia(payload.optionId)
const locale = request.input('locale', 'en')
return this.zimService.selectWikipedia(payload.optionId, locale)
}
}

View File

@ -12,6 +12,7 @@ import {
getFileStatsIfExists,
ZIM_STORAGE_PATH,
} from '../utils/fs.js'
import { getRepoRawUrl } from '../utils/misc.js'
import type {
ManifestType,
ZimCategoriesSpec,
@ -22,10 +23,13 @@ import type {
SpecTier,
} from '../../types/collections.js'
const SPEC_URLS: Record<ManifestType, string> = {
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
function getSpecUrls(): Record<ManifestType, string> {
const rawUrl = getRepoRawUrl()
return {
zim_categories: `${rawUrl}/collections/kiwix-categories.json`,
maps: `${rawUrl}/collections/maps.json`,
wikipedia: `${rawUrl}/collections/wikipedia.json`,
}
}
const VALIDATORS: Record<ManifestType, any> = {
@ -41,7 +45,7 @@ export class CollectionManifestService {
async fetchAndCacheSpec(type: ManifestType): Promise<boolean> {
try {
const response = await axios.get(SPEC_URLS[type], { timeout: 15000 })
const response = await axios.get(getSpecUrls()[type], { timeout: 15000 })
const validated = await vine.validate({
schema: VALIDATORS[type],

View File

@ -12,6 +12,7 @@ import { promisify } from 'util'
// import { readdir } from 'fs/promises'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { getRepoUrl } from '../utils/misc.js'
@inject()
export class DockerService {
@ -615,7 +616,7 @@ export class DockerService {
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
**/
const WIKIPEDIA_ZIM_URL =
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim'
`${getRepoUrl()}/raw/refs/heads/main/install/wikipedia_en_100_mini_2025-06.zim`
const filename = 'wikipedia_en_100_mini_2025-06.zim'
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)

View File

@ -14,6 +14,7 @@ import env from '#start/env'
import KVStore from '#models/kv_store'
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
import { isNewerVersion } from '../utils/version.js'
import { getRepoUrl } from '../utils/misc.js'
@inject()
@ -339,15 +340,17 @@ export class SystemService {
let latestVersion: string
if (earlyAccess) {
const repoPath = getRepoUrl().replace('https://github.com/', '')
const response = await axios.get(
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases',
`https://api.github.com/repos/${repoPath}/releases`,
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
)
if (!response?.data?.length) throw new Error('No releases found')
latestVersion = response.data[0].tag_name.replace(/^v/, '').trim()
} else {
const repoPath = getRepoUrl().replace('https://github.com/', '')
const response = await axios.get(
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
`https://api.github.com/repos/${repoPath}/releases/latest`,
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
)
if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API')

View File

@ -28,7 +28,13 @@ import { CollectionManifestService } from './collection_manifest_service.js'
import type { CategoryWithStatus } from '../../types/collections.js'
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
import { getRepoRawUrl } from '../utils/misc.js'
const WIKIPEDIA_DEFAULT_LOCALE = 'en'
function getWikipediaOptionsUrl(locale: string): string {
return `${getRepoRawUrl()}/collections/wikipedia.${locale}.json`
}
@inject()
export class ZimService {
@ -252,7 +258,7 @@ export class ZimService {
async downloadRemoteSuccessCallback(urls: string[], restart = true) {
// Check if any URL is a Wikipedia download and handle it
for (const url of urls) {
if (url.includes('wikipedia_en_')) {
if (url.includes('/wikipedia_')) {
await this.onWikipediaDownloadComplete(url, true)
}
}
@ -296,7 +302,7 @@ export class ZimService {
// Create InstalledResource entries for downloaded files
for (const url of urls) {
// Skip Wikipedia files (managed separately)
if (url.includes('wikipedia_en_')) continue
if (url.includes('/wikipedia_')) continue
const filename = url.split('/').pop()
if (!filename) continue
@ -360,9 +366,10 @@ export class ZimService {
// Wikipedia selector methods
async getWikipediaOptions(): Promise<WikipediaOption[]> {
async getWikipediaOptions(locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise<WikipediaOption[]> {
try {
const response = await axios.get(WIKIPEDIA_OPTIONS_URL)
const url = getWikipediaOptionsUrl(locale)
const response = await axios.get(url)
const data = response.data
const validated = await vine.validate({
@ -372,6 +379,11 @@ export class ZimService {
return validated.options
} catch (error) {
// Fall back to default locale if the requested locale file doesn't exist
if (locale !== WIKIPEDIA_DEFAULT_LOCALE) {
logger.warn(`[ZimService] Wikipedia options not found for locale '${locale}', falling back to '${WIKIPEDIA_DEFAULT_LOCALE}'`)
return this.getWikipediaOptions(WIKIPEDIA_DEFAULT_LOCALE)
}
logger.error(`[ZimService] Failed to fetch Wikipedia options:`, error)
throw new Error('Failed to fetch Wikipedia options')
}
@ -382,8 +394,8 @@ export class ZimService {
return WikipediaSelection.query().first()
}
async getWikipediaState(): Promise<WikipediaState> {
const options = await this.getWikipediaOptions()
async getWikipediaState(locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise<WikipediaState> {
const options = await this.getWikipediaOptions(locale)
const selection = await this.getWikipediaSelection()
return {
@ -399,8 +411,8 @@ export class ZimService {
}
}
async selectWikipedia(optionId: string): Promise<{ success: boolean; jobId?: string; message?: string }> {
const options = await this.getWikipediaOptions()
async selectWikipedia(optionId: string, locale: string = WIKIPEDIA_DEFAULT_LOCALE): Promise<{ success: boolean; jobId?: string; message?: string }> {
const options = await this.getWikipediaOptions(locale)
const selectedOption = options.find((opt) => opt.id === optionId)
if (!selectedOption) {
@ -537,7 +549,7 @@ export class ZimService {
// We need to find what was previously installed
const existingFiles = await this.list()
const wikipediaFiles = existingFiles.files.filter((f) =>
f.name.startsWith('wikipedia_en_') && f.name !== selection.filename
f.name.startsWith('wikipedia_') && f.name !== selection.filename
)
for (const oldFile of wikipediaFiles) {

View File

@ -1,3 +1,18 @@
const DEFAULT_REPO_URL = 'https://github.com/Crosstalk-Solutions/project-nomad'
export function getRepoUrl(): string {
return (process.env.NOMAD_REPO_URL || DEFAULT_REPO_URL).replace(/\/+$/, '')
}
export function getRepoRawUrl(branch: string = 'main'): string {
const repoUrl = getRepoUrl()
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/)
if (match) {
return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/refs/heads/${branch}`
}
return `${repoUrl}/raw/refs/heads/${branch}`
}
export function formatSpeed(bytesPerSecond: number): string {
if (bytesPerSecond < 1024) return `${bytesPerSecond.toFixed(0)} B/s`
if (bytesPerSecond < 1024 * 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`

View File

@ -2,6 +2,7 @@
/// <reference path="../../config/inertia.ts" />
import '../css/app.css'
import '../lib/i18n'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'

View File

@ -4,6 +4,7 @@ import { extractFileName } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
import api from '~/lib/api'
import { useTranslation } from 'react-i18next'
interface ActiveDownloadProps {
filetype?: useDownloadsProps['filetype']
@ -11,6 +12,7 @@ interface ActiveDownloadProps {
}
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
const { t } = useTranslation()
const { data: downloads, invalidate } = useDownloads({ filetype })
const handleDismiss = async (jobId: string) => {
@ -20,7 +22,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
return (
<>
{withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />}
{withHeader && <StyledSectionHeader title={t('common.activeDownloads')} className="mt-12 mb-4" />}
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
@ -67,7 +69,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
</div>
))
) : (
<p className="text-text-muted">No active downloads</p>
<p className="text-text-muted">{t('common.noActiveDownloads')}</p>
)}
</div>
</>

View File

@ -1,18 +1,20 @@
import useEmbedJobs from '~/hooks/useEmbedJobs'
import HorizontalBarChart from './HorizontalBarChart'
import StyledSectionHeader from './StyledSectionHeader'
import { useTranslation } from 'react-i18next'
interface ActiveEmbedJobsProps {
withHeader?: boolean
}
const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
const { t } = useTranslation()
const { data: jobs } = useEmbedJobs()
return (
<>
{withHeader && (
<StyledSectionHeader title="Processing Queue" className="mt-12 mb-4" />
<StyledSectionHeader title={t('common.processingQueue')} className="mt-12 mb-4" />
)}
<div className="space-y-4">
{jobs && jobs.length > 0 ? (
@ -35,7 +37,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
</div>
))
) : (
<p className="text-text-muted">No files are currently being processed</p>
<p className="text-text-muted">{t('common.noFilesProcessing')}</p>
)}
</div>
</>

View File

@ -1,17 +1,19 @@
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import StyledSectionHeader from './StyledSectionHeader'
import { useTranslation } from 'react-i18next'
interface ActiveModelDownloadsProps {
withHeader?: boolean
}
const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps) => {
const { t } = useTranslation()
const { downloads } = useOllamaModelDownloads()
return (
<>
{withHeader && <StyledSectionHeader title="Active Model Downloads" className="mt-12 mb-4" />}
{withHeader && <StyledSectionHeader title={t('common.activeModelDownloads')} className="mt-12 mb-4" />}
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
@ -33,7 +35,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
</div>
))
) : (
<p className="text-text-muted">No active model downloads</p>
<p className="text-text-muted">{t('common.noActiveModelDownloads')}</p>
)}
</div>
</>

View File

@ -1,4 +1,5 @@
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'
@ -11,6 +12,7 @@ export interface CategoryCardProps {
}
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)
@ -61,9 +63,9 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
<div className="mt-4 pt-4 border-t border-white/20">
<p className="text-sm text-gray-300 mb-2">
{category.tiers.length} tiers available
{t('contentExplorer.categoryCard.tiersAvailable', { count: category.tiers.length })}
{!highlightedTierSlug && (
<span className="text-gray-400"> - Click to choose</span>
<span className="text-gray-400"> - {t('contentExplorer.categoryCard.clickToChoose')}</span>
)}
</p>
<div className="flex flex-wrap gap-2">
@ -86,7 +88,7 @@ const CategoryCard: React.FC<CategoryCardProps> = ({ category, selectedTier, onC
})}
</div>
<p className="text-gray-300 text-xs mt-3">
Size: {formatBytes(minSize, 1)} - {formatBytes(maxSize, 1)}
{t('contentExplorer.categoryCard.size', { min: formatBytes(minSize, 1), max: formatBytes(maxSize, 1) })}
</p>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { formatBytes } from '~/lib/util'
import { useTranslation } from 'react-i18next'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
import type { CollectionWithStatus } from '../../types/collections'
import classNames from 'classnames'
@ -11,6 +12,7 @@ export interface CuratedCollectionCardProps {
}
const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collection, onClick, size = 'small' }) => {
const { t } = useTranslation()
const totalSizeBytes = collection.resources?.reduce(
(acc, resource) => acc + resource.size_mb * 1024 * 1024,
0
@ -41,16 +43,16 @@ const CuratedCollectionCard: React.FC<CuratedCollectionCardProps> = ({ collectio
<div className="flex items-center">
<IconCircleCheck
className="w-5 h-5 text-lime-400 ml-2"
title="All items downloaded"
title={t('common.allItemsDownloaded')}
/>
<p className="text-lime-400 text-sm ml-1">All items downloaded</p>
<p className="text-lime-400 text-sm ml-1">{t('common.allItemsDownloaded')}</p>
</div>
)}
</div>
</div>
<p className="text-gray-200 grow">{collection.description}</p>
<p className="text-gray-200 text-xs mt-2">
Items: {collection.resources?.length} | Size: {formatBytes(totalSizeBytes, 0)}
{t('common.items', { count: collection.resources?.length, size: formatBytes(totalSizeBytes, 0) })}
</p>
</div>
)

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
import StyledModal from './StyledModal'
import api from '~/lib/api'
@ -9,6 +10,7 @@ interface DebugInfoModalProps {
}
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
const { t } = useTranslation()
const [debugText, setDebugText] = useState('')
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
@ -24,11 +26,11 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
const browserLine = `Browser: ${navigator.userAgent}`
setDebugText(text + '\n' + browserLine)
} else {
setDebugText('Failed to load debug info. Please try again.')
setDebugText(t('debugInfo.loadFailed'))
}
setLoading(false)
}).catch(() => {
setDebugText('Failed to load debug info. Please try again.')
setDebugText(t('debugInfo.loadFailed'))
setLoading(false)
})
}, [open])
@ -52,20 +54,19 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
<StyledModal
open={open}
onClose={onClose}
title="Debug Info"
title={t('debugInfo.title')}
icon={<IconBug className="size-8 text-desert-green" />}
cancelText="Close"
cancelText={t('debugInfo.close')}
onCancel={onClose}
>
<p className="text-sm text-gray-500 mb-3 text-left">
This is non-sensitive system info you can share when reporting issues.
No passwords, IPs, or API keys are included.
{t('debugInfo.description')}
</p>
<textarea
id="debug-info-text"
readOnly
value={loading ? 'Loading...' : debugText}
value={loading ? t('common.loadingEllipsis') : debugText}
rows={18}
className="w-full font-mono text-xs text-black bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
/>
@ -79,12 +80,12 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
{copied ? (
<>
<IconCheck className="size-4" />
Copied!
{t('debugInfo.copied')}
</>
) : (
<>
<IconCopy className="size-4" />
Copy to Clipboard
{t('debugInfo.copyToClipboard')}
</>
)}
</button>
@ -95,7 +96,7 @@ export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
rel="noopener noreferrer"
className="text-sm text-desert-green hover:underline"
>
Open a GitHub Issue
{t('debugInfo.openGithubIssue')}
</a>
</div>
</StyledModal>

View File

@ -2,6 +2,7 @@ import { useState } from 'react'
import StyledModal, { StyledModalProps } from './StyledModal'
import Input from './inputs/Input'
import api from '~/lib/api'
import { useTranslation } from 'react-i18next'
export type DownloadURLModalProps = Omit<
StyledModalProps,
@ -16,6 +17,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
onPreflightSuccess,
...modalProps
}) => {
const { t } = useTranslation()
const [url, setUrl] = useState<string>('')
const [messages, setMessages] = useState<string[]>([])
const [loading, setLoading] = useState<boolean>(false)
@ -23,10 +25,10 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
async function runPreflightCheck(downloadUrl: string) {
try {
setLoading(true)
setMessages([`Running preflight check for URL: ${downloadUrl}`])
setMessages([t('mapsManager.preflightRunning', { url: downloadUrl })])
const res = await api.downloadRemoteMapRegionPreflight(downloadUrl)
if (!res) {
throw new Error('An unknown error occurred during the preflight check.')
throw new Error(t('mapsManager.preflightUnknownError'))
}
if ('message' in res) {
@ -35,7 +37,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
setMessages((prev) => [
...prev,
`Preflight check passed. Filename: ${res.filename}, Size: ${(res.size / (1024 * 1024)).toFixed(2)} MB`,
t('mapsManager.preflightPassed', { filename: res.filename, size: (res.size / (1024 * 1024)).toFixed(2) }),
])
if (onPreflightSuccess) {
@ -43,7 +45,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
}
} catch (error) {
console.error('Preflight check failed:', error)
setMessages((prev) => [...prev, `Preflight check failed: ${error.message}`])
setMessages((prev) => [...prev, t('mapsManager.preflightFailed', { error: error.message })])
} finally {
setLoading(false)
}
@ -54,9 +56,9 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
{...modalProps}
onConfirm={() => runPreflightCheck(url)}
open={true}
confirmText="Download"
confirmText={t('mapsManager.download')}
confirmIcon="IconDownload"
cancelText="Cancel"
cancelText={t('mapsManager.cancel')}
confirmVariant="primary"
confirmLoading={loading}
cancelLoading={loading}
@ -64,14 +66,12 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
>
<div className="flex flex-col pb-4">
<p className="text-text-secondary mb-8">
Enter the URL of the map region file you wish to download. The URL must be publicly
reachable and end with .pmtiles. A preflight check will be run to verify the file's
availability, type, and approximate size.
{t('mapsManager.downloadModalDescription')}
</p>
<Input
name="download-url"
label=""
placeholder={suggestedURL || 'Enter download URL...'}
placeholder={suggestedURL || t('mapsManager.downloadModalPlaceholder')}
className="mb-4"
value={url}
onChange={(e) => setUrl(e.target.value)}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system'
import ThemeToggle from '~/components/ThemeToggle'
@ -6,6 +7,7 @@ import { IconBug } from '@tabler/icons-react'
import DebugInfoModal from './DebugInfoModal'
export default function Footer() {
const { t } = useTranslation()
const { appVersion } = usePage().props as unknown as UsePageProps
const [debugModalOpen, setDebugModalOpen] = useState(false)
@ -13,7 +15,7 @@ export default function Footer() {
<footer>
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
<p className="text-sm/6 text-text-secondary">
Project N.O.M.A.D. Command Center v{appVersion}
{t('footer.commandCenter', { version: appVersion })}
</p>
<span className="text-gray-300">|</span>
<button
@ -21,7 +23,7 @@ export default function Footer() {
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info
{t('footer.debugInfo')}
</button>
<ThemeToggle />
</div>

View File

@ -1,5 +1,6 @@
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
import classNames from '~/lib/classNames'
import { useTranslation } from 'react-i18next'
export type InstallActivityFeedProps = {
activity: Array<{
@ -30,9 +31,10 @@ export type InstallActivityFeedProps = {
}
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
const { t } = useTranslation()
return (
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
{withHeader && <h2 className="text-lg font-semibold text-text-primary">{t('common.installationActivity')}</h2>}
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
{activity.map((activityItem, activityItemIdx) => (
<li key={activityItem.timestamp} className="relative flex gap-x-4">

View File

@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
interface LoadingSpinnerProps {
text?: string
fullscreen?: boolean
@ -13,6 +15,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
light = false,
className,
}) => {
const { t } = useTranslation()
if (!fullscreen) {
return (
<div className="flex flex-col items-center justify-center">
@ -21,7 +24,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
></div>
{!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
{text || 'Loading...'}
{text || t('common.loadingEllipsis')}
</div>
)}
</div>
@ -31,7 +34,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
return (
<div className={className}>
<div className="ui active inverted dimmer">
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div>
<div className="ui text loader">{!iconOnly && <span>{text || t('common.loading')}</span>}</div>
</div>
</div>
)

View File

@ -1,8 +1,11 @@
import { useTranslation } from 'react-i18next'
const ProgressBar = ({ progress, speed }: { progress: number; speed?: string }) => {
const { t } = useTranslation()
if (progress >= 100) {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-desert-green">Download complete</span>
<span className="text-sm text-desert-green">{t('common.downloadComplete')}</span>
</div>
)
}
@ -17,7 +20,7 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
</div>
{speed && (
<div className="mt-1 text-sm text-text-muted">
Est. Speed: {speed}
{t('common.estSpeed', { speed })}
</div>
)}
</div>

View File

@ -1,6 +1,7 @@
import classNames from '~/lib/classNames'
import { formatBytes } from '~/lib/util'
import { IconAlertTriangle, IconServer } from '@tabler/icons-react'
import { useTranslation } from 'react-i18next'
interface StorageProjectionBarProps {
totalSize: number // Total disk size in bytes
@ -13,6 +14,7 @@ export default function StorageProjectionBar({
currentUsed,
projectedAddition,
}: StorageProjectionBarProps) {
const { t } = useTranslation()
const projectedTotal = currentUsed + projectedAddition
const currentPercent = (currentUsed / totalSize) * 100
const projectedPercent = (projectedAddition / totalSize) * 100
@ -40,13 +42,13 @@ export default function StorageProjectionBar({
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<IconServer size={20} className="text-desert-green" />
<span className="font-semibold text-desert-green">Storage</span>
<span className="font-semibold text-desert-green">{t('common.storage')}</span>
</div>
<div className="text-sm text-desert-stone-dark font-mono">
{formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)}
{projectedAddition > 0 && (
<span className="text-desert-stone ml-2">
(+{formatBytes(projectedAddition, 1)} selected)
(+{formatBytes(projectedAddition, 1)} {t('common.selected')})
</span>
)}
</div>
@ -94,13 +96,13 @@ export default function StorageProjectionBar({
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded bg-desert-stone" />
<span className="text-desert-stone-dark">Current ({formatBytes(currentUsed, 1)})</span>
<span className="text-desert-stone-dark">{t('common.current', { size: formatBytes(currentUsed, 1) })}</span>
</div>
{projectedAddition > 0 && (
<div className="flex items-center gap-1.5">
<div className={classNames('w-3 h-3 rounded', getProjectedColor())} />
<span className="text-desert-stone-dark">
Selected (+{formatBytes(projectedAddition, 1)})
{t('common.selectedAddition', { size: formatBytes(projectedAddition, 1) })}
</span>
</div>
)}
@ -109,11 +111,11 @@ export default function StorageProjectionBar({
{willExceed ? (
<div className="flex items-center gap-1.5 text-desert-red text-xs font-medium">
<IconAlertTriangle size={14} />
<span>Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)}</span>
<span>{t('common.exceedsSpace', { size: formatBytes(projectedTotal - totalSize, 1) })}</span>
</div>
) : (
<div className="text-xs text-desert-stone">
{formatBytes(remainingAfter, 1)} will remain free
{t('common.willRemainFree', { size: formatBytes(remainingAfter, 1) })}
</div>
)}
</div>

View File

@ -2,6 +2,7 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/re
import StyledButton, { StyledButtonProps } from './StyledButton'
import React from 'react'
import classNames from '~/lib/classNames'
import { useTranslation } from 'react-i18next'
export type StyledModalProps = {
onClose?: () => void
@ -26,10 +27,10 @@ const StyledModal: React.FC<StyledModalProps> = ({
title,
open,
onClose,
cancelText = 'Cancel',
cancelText,
cancelIcon,
cancelLoading = false,
confirmText = 'Confirm',
confirmText,
confirmIcon,
confirmVariant = 'action',
confirmLoading = false,
@ -38,6 +39,9 @@ const StyledModal: React.FC<StyledModalProps> = ({
icon,
large = false,
}) => {
const { t } = useTranslation()
const resolvedCancelText = cancelText ?? t('common.cancel')
const resolvedConfirmText = confirmText ?? t('common.confirm')
return (
<Dialog
open={open}
@ -74,7 +78,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
{cancelText && onCancel && (
{resolvedCancelText && onCancel && (
<StyledButton
variant="outline"
onClick={() => {
@ -83,10 +87,10 @@ const StyledModal: React.FC<StyledModalProps> = ({
icon={cancelIcon}
loading={cancelLoading}
>
{cancelText}
{resolvedCancelText}
</StyledButton>
)}
{confirmText && onConfirm && (
{resolvedConfirmText && onConfirm && (
<StyledButton
variant={confirmVariant}
onClick={() => {
@ -95,7 +99,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
icon={confirmIcon}
loading={confirmLoading}
>
{confirmText}
{resolvedConfirmText}
</StyledButton>
)}
</div>

View File

@ -3,6 +3,7 @@ import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessu
import classNames from '~/lib/classNames'
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
import { usePage } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import { UsePageProps } from '../../types/system'
import { IconMenu2, IconX } from '@tabler/icons-react'
import ThemeToggle from '~/components/ThemeToggle'
@ -22,6 +23,7 @@ interface StyledSidebarProps {
}
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
const { t } = useTranslation()
const [sidebarOpen, setSidebarOpen] = useState(false)
const [debugModalOpen, setDebugModalOpen] = useState(false)
const { appVersion } = usePage().props as unknown as UsePageProps
@ -71,7 +73,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold"
>
<IconArrowLeft aria-hidden="true" className="size-6 shrink-0" />
Back to Home
{t('common.backToHome')}
</a>
</li>
</ul>
@ -121,7 +123,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
onClick={() => setSidebarOpen(false)}
className="-m-2.5 p-2.5"
>
<span className="sr-only">Close sidebar</span>
<span className="sr-only">{t('common.closeSidebar')}</span>
<IconX aria-hidden="true" className="size-6 text-white" />
</button>
</div>

View File

@ -2,6 +2,7 @@ import { capitalizeFirstLetter } from '~/lib/util'
import classNames from '~/lib/classNames'
import LoadingSpinner from '~/components/LoadingSpinner'
import React, { RefObject, useState } from 'react'
import { useTranslation } from 'react-i18next'
export type StyledTableProps<T extends { [key: string]: any }> = {
loading?: boolean
@ -38,7 +39,7 @@ function StyledTable<T extends { [key: string]: any }>({
tableBodyClassName = '',
tableBodyStyle = {},
data = [],
noDataText = 'No records found',
noDataText,
onRowClick,
columns = [],
className = '',
@ -48,6 +49,8 @@ function StyledTable<T extends { [key: string]: any }>({
compact = false,
expandable,
}: StyledTableProps<T>) {
const { t } = useTranslation()
const resolvedNoDataText = noDataText ?? t('common.noRecordsFound')
const { className: tableClassName, ...restTableProps } = tableProps
const [expandedRowKeys, setExpandedRowKeys] = useState<(string | number)[]>(
@ -184,7 +187,7 @@ function StyledTable<T extends { [key: string]: any }>({
{!loading && data.length === 0 && (
<tr>
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
{noDataText}
{resolvedNoDataText}
</td>
</tr>
)}

View File

@ -5,6 +5,7 @@ import type { CategoryWithStatus, SpecTier, SpecResource } from '../../types/col
import { resolveTierResources } from '~/lib/collections'
import { formatBytes } from '~/lib/util'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
interface TierSelectionModalProps {
@ -22,6 +23,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
selectedTierSlug,
onSelectTier,
}) => {
const { t } = useTranslation()
// Local selection state - initialized from prop
const [localSelectedSlug, setLocalSelectedSlug] = useState<string | null>(null)
@ -116,7 +118,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Content */}
<div className="p-6">
<p className="text-text-secondary mb-6">
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
{t('contentExplorer.tierModal.selectTierDescription')}
</p>
<div className="space-y-4">
@ -149,7 +151,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</h3>
{includedTierName && (
<span className="text-xs text-text-muted">
(includes {includedTierName})
({t('contentExplorer.tierModal.includes', { name: includedTierName })})
</span>
)}
</div>
@ -160,11 +162,11 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<p className="text-xs text-text-muted mb-2 font-medium">
{includedTierName ? (
<>
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
<span className="text-text-muted"> (plus everything in {includedTierName})</span>
{t('contentExplorer.tierModal.additionalResources', { count: ownResourceCount })}
<span className="text-text-muted"> ({t('contentExplorer.tierModal.plusEverythingIn', { name: includedTierName })})</span>
</>
) : (
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
<>{t('contentExplorer.tierModal.resourcesIncluded', { count: ownResourceCount })}</>
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@ -206,7 +208,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
<p>
You can change your selection at any time. Click Submit to confirm your choice.
{t('contentExplorer.tierModal.changeNote')}
</p>
</div>
</div>
@ -223,7 +225,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
: 'bg-border-default text-text-muted cursor-not-allowed'
)}
>
Submit
{t('contentExplorer.tierModal.submit')}
</button>
</div>
</Dialog.Panel>

View File

@ -1,4 +1,5 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ServiceSlim } from "../../types/services"
import StyledModal from "./StyledModal"
import { IconArrowUp } from "@tabler/icons-react"
@ -22,6 +23,7 @@ export default function UpdateServiceModal({
onUpdate,
showError,
}: UpdateServiceModalProps) {
const { t } = useTranslation()
const [selectedVersion, setSelectedVersion] = useState(latestVersion)
const [showAdvanced, setShowAdvanced] = useState(false)
const [versions, setVersions] = useState<Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>>([])
@ -36,7 +38,7 @@ export default function UpdateServiceModal({
setVersions(result.versions)
}
} catch (error) {
showError('Failed to load available versions')
showError(t('updateService.failedToLoadVersions'))
} finally {
setLoadingVersions(false)
}
@ -50,23 +52,25 @@ export default function UpdateServiceModal({
return (
<StyledModal
title="Update Service"
title={t('updateService.title')}
onConfirm={() => onUpdate(selectedVersion)}
onCancel={onCancel}
open={true}
confirmText="Update"
cancelText="Cancel"
confirmText={t('updateService.update')}
confirmVariant="primary"
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
>
<div className="space-y-4">
<p className="text-text-primary">
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
</p>
<p className="text-text-primary" dangerouslySetInnerHTML={{
__html: t('updateService.updateConfirmation', {
name: record.friendly_name || record.service_name,
from: currentTag,
to: selectedVersion,
interpolation: { escapeValue: false }
})
}} />
<p className="text-sm text-text-muted">
Your data and configuration will be preserved during the update.
{t('updateService.dataPreserved')}
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
<>
{' '}
@ -76,7 +80,7 @@ export default function UpdateServiceModal({
rel="noopener noreferrer"
className="text-desert-green hover:underline"
>
View release notes
{t('updateService.viewReleaseNotes')}
</a>
</>
)}
@ -88,16 +92,16 @@ export default function UpdateServiceModal({
onClick={handleToggleAdvanced}
className="text-sm text-desert-green hover:underline font-medium"
>
{showAdvanced ? 'Hide' : 'Show'} available versions
{showAdvanced ? t('updateService.hideVersions') : t('updateService.showVersions')}
</button>
{showAdvanced && (
<>
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
{loadingVersions ? (
<div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
<div className="p-4 text-center text-text-muted text-sm">{t('updateService.loadingVersions')}</div>
) : versions.length === 0 ? (
<div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
<div className="p-4 text-center text-text-muted text-sm">{t('updateService.noVersionsAvailable')}</div>
) : (
versions.map((v) => (
<label
@ -115,7 +119,7 @@ export default function UpdateServiceModal({
<span className="text-sm font-medium text-text-primary">{v.tag}</span>
{v.isLatest && (
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
Latest
{t('updateService.latest')}
</span>
)}
{v.releaseUrl && (
@ -126,7 +130,7 @@ export default function UpdateServiceModal({
className="ml-auto text-xs text-desert-green hover:underline"
onClick={(e) => e.stopPropagation()}
>
Release notes
{t('updateService.releaseNotes')}
</a>
)}
</label>
@ -134,7 +138,7 @@ export default function UpdateServiceModal({
)}
</div>
<p className="mt-2 text-sm text-text-muted">
It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
{t('updateService.majorVersionWarning')}
</p>
</>
)}

View File

@ -1,4 +1,5 @@
import { formatBytes } from '~/lib/util'
import { useTranslation } from 'react-i18next'
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
import classNames from 'classnames'
import { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'
@ -26,6 +27,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
onSubmit,
isSubmitting = false,
}) => {
const { t } = useTranslation()
// Determine which option to highlight
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
@ -41,8 +43,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
<IconWorld className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
<p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
<h3 className="text-xl font-semibold text-text-primary">{t('contentExplorer.wikipedia.title')}</h3>
<p className="text-sm text-text-muted">{t('contentExplorer.wikipedia.selectPackage')}</p>
</div>
</div>
@ -51,7 +53,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
<span className="text-sm text-blue-700">
Downloading Wikipedia... This may take a while for larger packages.
{t('contentExplorer.wikipedia.downloading')}
</span>
</div>
)}
@ -101,18 +103,18 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
{isInstalled && (
<span className="text-xs bg-desert-green text-white px-2 py-0.5 rounded-full flex items-center gap-1">
<IconCheck size={12} />
Installed
{t('contentExplorer.wikipedia.installed')}
</span>
)}
{isPending && !isInstalled && (
<span className="text-xs bg-lime-500 text-white px-2 py-0.5 rounded-full">
Selected
{t('contentExplorer.wikipedia.selected')}
</span>
)}
{isCurrentDownloading && (
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1">
<IconDownload size={12} />
Downloading
{t('contentExplorer.wikipedia.downloadingBadge')}
</span>
)}
{isCurrentFailed && (
@ -147,7 +149,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
)}
>
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
{option.size_mb === 0 ? t('contentExplorer.wikipedia.noDownload') : formatBytes(option.size_mb * 1024 * 1024, 1)}
</span>
</div>
</div>
@ -166,7 +168,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
loading={isSubmitting}
icon="IconDownload"
>
{selectedOptionId === 'none' ? 'Remove Wikipedia' : 'Download Selected'}
{selectedOptionId === 'none' ? t('contentExplorer.wikipedia.removeWikipedia') : t('contentExplorer.wikipedia.downloadSelected')}
</StyledButton>
</div>
)}

View File

@ -1,6 +1,7 @@
import { IconSend, IconWand } from '@tabler/icons-react'
import { useState, useRef, useEffect } from 'react'
import classNames from '~/lib/classNames'
import { useTranslation } from 'react-i18next'
import { ChatMessage } from '../../../types/chat'
import ChatMessageBubble from './ChatMessageBubble'
import ChatAssistantAvatar from './ChatAssistantAvatar'
@ -30,6 +31,7 @@ export default function ChatInterface({
chatSuggestionsLoading = false,
rewriteModelAvailable = false
}: ChatInterfaceProps) {
const { t } = useTranslation()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { addNotification } = useNotifications()
const [input, setInput] = useState('')
@ -42,9 +44,9 @@ export default function ChatInterface({
setIsDownloading(true)
try {
await api.downloadModel(DEFAULT_QUERY_REWRITE_MODEL)
addNotification({ type: 'success', message: 'Model download queued' })
addNotification({ type: 'success', message: t('chat.modelDownloadQueued') })
} catch (error) {
addNotification({ type: 'error', message: 'Failed to queue model download' })
addNotification({ type: 'error', message: t('chat.modelDownloadFailed') })
} finally {
setIsDownloading(false)
setDownloadDialogOpen(false)
@ -91,13 +93,13 @@ export default function ChatInterface({
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
<h3 className="text-lg font-medium text-text-primary mb-2">{t('chat.startConversation')}</h3>
<p className="text-text-muted text-sm">
Interact with your installed language models directly in the Command Center.
{t('chat.interactWithModels')}
</p>
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
<div className="mt-8">
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
<h4 className="text-sm font-medium text-text-secondary mb-2">{t('chat.suggestions')}</h4>
<div className="flex flex-col gap-2">
{chatSuggestions.map((suggestion, index) => (
<button
@ -118,10 +120,10 @@ export default function ChatInterface({
</div>
)}
{/* Display bouncing dots while loading suggestions */}
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text={t('chat.thinking')} containerClassName="mt-8" />}
{!chatSuggestionsEnabled && (
<div className="mt-8 text-sm text-text-muted">
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
{t('chat.enableSuggestions')}
</div>
)}
</div>
@ -145,7 +147,7 @@ export default function ChatInterface({
<div className="flex gap-4 justify-start">
<ChatAssistantAvatar />
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
<BouncingDots text="Thinking" />
<BouncingDots text={t('chat.thinking')} />
</div>
</div>
)}
@ -162,7 +164,7 @@ export default function ChatInterface({
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
placeholder={t('chat.typePlaceholder', { name: aiAssistantName })}
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
rows={1}
disabled={isLoading}
@ -188,21 +190,23 @@ export default function ChatInterface({
</form>
{!rewriteModelAvailable && (
<div className="text-sm text-text-muted mt-2">
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
{t('chat.ragModelNotInstalled', { model: DEFAULT_QUERY_REWRITE_MODEL })
.split('<button>')[0]}
<button
onClick={() => setDownloadDialogOpen(true)}
className="text-desert-green underline hover:text-desert-green/80 cursor-pointer"
>
downloading it
</button>{' '}
for improved retrieval-augmented generation (RAG) performance.
{t('chat.ragModelNotInstalled', { model: DEFAULT_QUERY_REWRITE_MODEL })
.split('<button>')[1]?.split('</button>')[0]}
</button>
{t('chat.ragModelNotInstalled', { model: DEFAULT_QUERY_REWRITE_MODEL })
.split('</button>')[1]}
</div>
)}
<StyledModal
open={downloadDialogOpen}
title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}
confirmText="Download"
cancelText="Cancel"
title={t('chat.downloadModelTitle', { model: DEFAULT_QUERY_REWRITE_MODEL })}
confirmText={t('chat.download')}
confirmIcon='IconDownload'
confirmVariant='primary'
confirmLoading={isDownloading}
@ -210,11 +214,9 @@ export default function ChatInterface({
onCancel={() => setDownloadDialogOpen(false)}
onClose={() => setDownloadDialogOpen(false)}
>
<p className="text-text-primary">
This will dispatch a background download job for{' '}
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
will be used to rewrite queries for improved RAG retrieval performance.
</p>
<p className="text-text-primary" dangerouslySetInnerHTML={{
__html: t('chat.downloadModelDescription', { model: DEFAULT_QUERY_REWRITE_MODEL })
}} />
</StyledModal>
</div>
</div>

View File

@ -1,6 +1,7 @@
import classNames from '~/lib/classNames'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useTranslation } from 'react-i18next'
import { ChatMessage } from '../../../types/chat'
export interface ChatMessageBubbleProps {
@ -8,6 +9,7 @@ export interface ChatMessageBubbleProps {
}
export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
const { t } = useTranslation()
return (
<div
className={classNames(
@ -18,7 +20,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
{message.isThinking && message.thinking && (
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-medium text-amber-700">
<span>Reasoning</span>
<span>{t('chat.reasoning')}</span>
<span className="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse inline-block" />
</div>
<div className="prose prose-xs max-w-none text-amber-900/80 max-h-32 overflow-y-auto">
@ -30,8 +32,8 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
<details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
<summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
{message.thinkingDuration !== undefined
? `Thought for ${message.thinkingDuration}s`
: 'Reasoning'}
? t('chat.thoughtFor', { seconds: message.thinkingDuration })
: t('chat.reasoning')}
</summary>
<div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>

View File

@ -4,6 +4,7 @@ import { router, usePage } from '@inertiajs/react'
import { ChatSession } from '../../../types/chat'
import { IconMessage } from '@tabler/icons-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import KnowledgeBaseModal from './KnowledgeBaseModal'
interface ChatSidebarProps {
@ -23,6 +24,7 @@ export default function ChatSidebar({
onClearHistory,
isInModal = false,
}: ChatSidebarProps) {
const { t } = useTranslation()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const [isKnowledgeBaseModalOpen, setIsKnowledgeBaseModalOpen] = useState(
() => new URLSearchParams(window.location.search).get('knowledge_base') === 'true'
@ -42,13 +44,13 @@ export default function ChatSidebar({
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
New Chat
{t('chat.newChat')}
</StyledButton>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
<div className="p-4 text-center text-text-muted text-sm">{t('chat.noPreviousChats')}</div>
) : (
<div className="p-2 space-y-1">
{sessions.map((session) => (
@ -103,7 +105,7 @@ export default function ChatSidebar({
size="sm"
fullWidth
>
{isInModal ? 'Open in New Tab' : 'Back to Home'}
{isInModal ? t('chat.openInNewTab') : t('common.backToHome')}
</StyledButton>
<StyledButton
onClick={() => {
@ -114,7 +116,7 @@ export default function ChatSidebar({
size="sm"
fullWidth
>
Models & Settings
{t('chat.modelsAndSettings')}
</StyledButton>
<StyledButton
onClick={() => {
@ -125,7 +127,7 @@ export default function ChatSidebar({
size="sm"
fullWidth
>
Knowledge Base
{t('chat.knowledgeBase')}
</StyledButton>
{sessions.length > 0 && (
<StyledButton
@ -135,7 +137,7 @@ export default function ChatSidebar({
size="sm"
fullWidth
>
Clear History
{t('chat.clearHistory')}
</StyledButton>
)}
</div>

View File

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import FileUploader from '~/components/file-uploader'
import StyledButton from '~/components/StyledButton'
import StyledSectionHeader from '~/components/StyledSectionHeader'
@ -22,6 +23,7 @@ function sourceToDisplayName(source: string): string {
}
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
const { t } = useTranslation()
const { addNotification } = useNotifications()
const [files, setFiles] = useState<File[]>([])
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
@ -40,7 +42,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'Document uploaded and queued for processing',
message: data?.message || t('chat.uploadSuccess'),
})
setFiles([])
if (fileUploaderRef.current) {
@ -50,7 +52,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onError: (error: any) => {
addNotification({
type: 'error',
message: error?.message || 'Failed to upload document',
message: error?.message || t('chat.uploadFailed'),
})
},
})
@ -58,12 +60,12 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const deleteMutation = useMutation({
mutationFn: (source: string) => api.deleteRAGFile(source),
onSuccess: () => {
addNotification({ type: 'success', message: 'File removed from knowledge base.' })
addNotification({ type: 'success', message: t('chat.fileRemoved') })
setConfirmDeleteSource(null)
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
},
onError: (error: any) => {
addNotification({ type: 'error', message: error?.message || 'Failed to delete file.' })
addNotification({ type: 'error', message: error?.message || t('chat.fileDeleteFailed') })
setConfirmDeleteSource(null)
},
})
@ -73,13 +75,13 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onSuccess: (data) => {
addNotification({
type: 'success',
message: data?.message || 'Storage synced successfully. If new files were found, they have been queued for processing.',
message: data?.message || t('chat.syncSuccess'),
})
},
onError: (error: any) => {
addNotification({
type: 'error',
message: error?.message || 'Failed to sync storage',
message: error?.message || t('chat.syncFailed'),
})
},
})
@ -93,7 +95,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
const handleConfirmSync = () => {
openModal(
<StyledModal
title='Confirm Sync?'
title={t('chat.confirmSync')}
onConfirm={() => {
syncMutation.mutate()
closeModal(
@ -102,13 +104,11 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
}}
onCancel={() => closeModal("confirm-sync-modal")}
open={true}
confirmText='Confirm Sync'
cancelText='Cancel'
confirmText={t('chat.confirmSyncButton')}
confirmVariant='primary'
>
<p className='text-text-primary'>
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
{t('chat.syncDescription')}
</p>
</StyledModal>,
"confirm-sync-modal"
@ -119,7 +119,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
<h2 className="text-2xl font-semibold text-text-primary">{t('chat.knowledgeBaseTitle')}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
@ -147,13 +147,13 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
disabled={files.length === 0 || uploadMutation.isPending}
loading={uploadMutation.isPending}
>
Upload
{t('common.upload')}
</StyledButton>
</div>
</div>
<div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
Why upload documents to your Knowledge Base?
{t('chat.whyUpload')}
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
@ -162,14 +162,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
<div>
<p className="font-medium text-desert-stone-dark">
{aiAssistantName} Knowledge Base Integration
{t('chat.kbIntegrationTitle', { name: aiAssistantName })}
</p>
<p className="text-sm text-desert-stone">
When you upload documents to your Knowledge Base, NOMAD processes and embeds
the content, making it directly accessible to {aiAssistantName}. This allows{' '}
{aiAssistantName} to reference your specific documents during conversations,
providing more accurate and personalized responses based on your uploaded
data.
{t('chat.kbIntegrationDescription', { name: aiAssistantName })}
</p>
</div>
</div>
@ -179,13 +175,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Enhanced Document Processing with OCR
{t('chat.ocrTitle')}
</p>
<p className="text-sm text-desert-stone">
NOMAD includes built-in Optical Character Recognition (OCR) capabilities,
allowing it to extract text from image-based documents such as scanned PDFs or
photos. This means that even if your documents are not in a standard text
format, NOMAD can still process and embed their content for AI access.
{t('chat.ocrDescription')}
</p>
</div>
</div>
@ -195,11 +188,10 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</div>
<div>
<p className="font-medium text-desert-stone-dark">
Information Library Integration
{t('chat.libraryTitle')}
</p>
<p className="text-sm text-desert-stone">
NOMAD will automatically discover and extract any content you save to your
Information Library (if installed), making it instantly available to {aiAssistantName} without any extra steps.
{t('chat.libraryDescription', { name: aiAssistantName })}
</p>
</div>
</div>
@ -212,7 +204,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
<div className="my-12">
<div className='flex items-center justify-between mb-6'>
<StyledSectionHeader title="Stored Knowledge Base Files" className='!mb-0' />
<StyledSectionHeader title={t('chat.storedFiles')} className='!mb-0' />
<StyledButton
variant="secondary"
size="md"
@ -221,7 +213,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
disabled={syncMutation.isPending || uploadMutation.isPending}
loading={syncMutation.isPending || uploadMutation.isPending}
>
Sync Storage
{t('chat.syncStorage')}
</StyledButton>
</div>
<StyledTable<{ source: string }>
@ -230,7 +222,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
columns={[
{
accessor: 'source',
title: 'File Name',
title: t('chat.fileName'),
render(record) {
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
},
@ -244,14 +236,14 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
if (isConfirming) {
return (
<div className="flex items-center gap-2 justify-end">
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
<span className="text-sm text-text-secondary">{t('chat.removeFromKb')}</span>
<StyledButton
variant='danger'
size='sm'
onClick={() => deleteMutation.mutate(record.source)}
disabled={isDeleting}
>
{isDeleting ? 'Deleting…' : 'Confirm'}
{isDeleting ? t('chat.deleting') : t('common.confirm')}
</StyledButton>
<StyledButton
variant='ghost'
@ -259,7 +251,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onClick={() => setConfirmDeleteSource(null)}
disabled={isDeleting}
>
Cancel
{t('common.cancel')}
</StyledButton>
</div>
)
@ -273,7 +265,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
onClick={() => setConfirmDeleteSource(record.source)}
disabled={deleteMutation.isPending}
loading={deleteMutation.isPending && confirmDeleteSource === record.source}
>Delete</StyledButton>
>{t('common.delete')}</StyledButton>
</div>
)
},

View File

@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import ChatSidebar from './ChatSidebar'
import ChatInterface from './ChatInterface'
import StyledModal from '../StyledModal'
@ -27,6 +28,7 @@ export default function Chat({
suggestionsEnabled = false,
streamingEnabled = true,
}: ChatProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
@ -101,7 +103,7 @@ export default function Chat({
const assistantMessage: ChatMessage = {
id: `msg-${Date.now()}-assistant`,
role: 'assistant',
content: data.message?.content || 'Sorry, I could not generate a response.',
content: data.message?.content || t('chat.errorNoResponse'),
timestamp: new Date(),
}
@ -116,7 +118,7 @@ export default function Chat({
const errorMessage: ChatMessage = {
id: `msg-${Date.now()}-error`,
role: 'assistant',
content: 'Sorry, there was an error processing your request. Please try again.',
content: t('chat.errorProcessing'),
timestamp: new Date(),
}
setMessages((prev) => [...prev, errorMessage])
@ -151,17 +153,15 @@ export default function Chat({
const handleClearHistory = useCallback(() => {
openModal(
<StyledModal
title="Clear All Chat History?"
title={t('chat.clearAllHistory')}
onConfirm={() => deleteAllSessionsMutation.mutate()}
onCancel={closeAllModals}
open={true}
confirmText="Clear All"
cancelText="Cancel"
confirmText={t('chat.clearAll')}
confirmVariant="danger"
>
<p className="text-text-primary">
Are you sure you want to delete all chat sessions? This action cannot be undone and all
conversations will be permanently deleted.
{t('chat.clearAllConfirm')}
</p>
</StyledModal>,
'confirm-clear-history-modal'
@ -203,7 +203,7 @@ export default function Chat({
// Create a new session if none exists
if (!sessionId) {
const newSession = await api.createChatSession('New Chat', selectedModel)
const newSession = await api.createChatSession(t('chat.newChat'), selectedModel)
if (newSession) {
sessionId = newSession.id
setActiveSessionId(sessionId)
@ -307,7 +307,7 @@ export default function Chat({
{
id: assistantMsgId,
role: 'assistant',
content: 'Sorry, there was an error processing your request. Please try again.',
content: t('chat.errorProcessing'),
timestamp: new Date(),
},
]
@ -360,17 +360,17 @@ export default function Chat({
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">
{activeSession?.title || 'New Chat'}
{activeSession?.title || t('chat.newChat')}
</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-text-secondary">
Model:
{t('chat.model')}
</label>
{isLoadingModels ? (
<div className="text-sm text-text-muted">Loading models...</div>
<div className="text-sm text-text-muted">{t('chat.loadingModels')}</div>
) : installedModels.length === 0 ? (
<div className="text-sm text-red-600">No models installed</div>
<div className="text-sm text-red-600">{t('chat.noModelsInstalled')}</div>
) : (
<select
id="model-select"

View File

@ -1,6 +1,7 @@
import { Link } from '@inertiajs/react'
import { IconArrowLeft } from '@tabler/icons-react'
import classNames from '~/lib/classNames'
import { useTranslation } from 'react-i18next'
interface BackToHomeHeaderProps {
className?: string
@ -8,12 +9,13 @@ interface BackToHomeHeaderProps {
}
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
const { t } = useTranslation()
return (
<div className={classNames('flex border-b border-border-subtle p-4', className)}>
<div className="justify-self-start">
<Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
<p className="text-lg text-text-secondary">{t('common.backToHome')}</p>
</Link>
</div>
<div className="flex-grow flex flex-col justify-center">{children}</div>

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Footer from '~/components/Footer'
import ChatButton from '~/components/chat/ChatButton'
import ChatModal from '~/components/chat/ChatModal'
@ -9,6 +10,7 @@ import { IconArrowLeft } from '@tabler/icons-react'
import classNames from 'classnames'
export default function AppLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const [isChatOpen, setIsChatOpen] = useState(false)
const aiAssistantInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
@ -18,7 +20,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
window.location.pathname !== '/home' && (
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
<p className="text-lg text-text-secondary">{t('layout.backToHome')}</p>
</Link>
)}
<div
@ -26,7 +28,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
onClick={() => (window.location.href = '/home')}
>
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-40 w-40" />
<h1 className="text-5xl font-bold text-desert-green">Command Center</h1>
<h1 className="text-5xl font-bold text-desert-green">{t('layout.commandCenter')}</h1>
</div>
<hr className={
classNames(

View File

@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import StyledSidebar from '~/components/StyledSidebar'
import api from '~/lib/api'
import { useTranslation } from 'react-i18next'
export default function DocsLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { data, isLoading } = useQuery<Array<{ title: string; slug: string }>>({
queryKey: ['docs'],
queryFn: () => api.listDocs(),
@ -23,7 +25,7 @@ export default function DocsLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex flex-row bg-desert-white">
<StyledSidebar title="Documentation" items={items} />
<StyledSidebar title={t('common.documentation')} items={items} />
{children}
</div>
)

View File

@ -12,43 +12,45 @@ import {
IconZoom
} from '@tabler/icons-react'
import { usePage } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import StyledSidebar from '~/components/StyledSidebar'
import { getServiceLink } from '~/lib/navigation'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import { SERVICE_NAMES } from '../../constants/service_names'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const aiAssistantInstallStatus = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
{ name: 'Apps', href: '/settings/apps', icon: IconTerminal2, current: false },
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
{ name: t('settingsNav.apps'), href: '/settings/apps', icon: IconTerminal2, current: false },
{ name: t('settingsNav.benchmark'), href: '/settings/benchmark', icon: IconChartBar, current: false },
{ name: t('settingsNav.contentExplorer'), href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
{ name: t('settingsNav.contentManager'), href: '/settings/zim', icon: IconFolder, current: false },
{ name: t('settingsNav.mapsManager'), href: '/settings/maps', icon: IconMapRoute, current: false },
{
name: 'Service Logs & Metrics',
name: t('settingsNav.serviceLogs'),
href: getServiceLink('9999'),
icon: IconDashboard,
current: false,
target: '_blank',
},
{
name: 'Check for Updates',
name: t('settingsNav.checkForUpdates'),
href: '/settings/update',
icon: IconArrowBigUpLines,
current: false,
},
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
{ name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
{ name: t('settingsNav.system'), href: '/settings/system', icon: IconSettings, current: false },
{ name: t('settingsNav.supportProject'), href: '/settings/support', icon: IconHeart, current: false },
{ name: t('settingsNav.legalNotices'), href: '/settings/legal', icon: IconGavel, current: false },
]
return (
<div className="min-h-screen flex flex-row bg-surface-secondary/90">
<StyledSidebar title="Settings" items={navigation} />
<StyledSidebar title={t('common.settings')} items={navigation} />
{children}
</div>
)

View File

@ -598,22 +598,25 @@ class API {
// Wikipedia selector methods
async getWikipediaState(): Promise<WikipediaState | undefined> {
async getWikipediaState(locale?: string): Promise<WikipediaState | undefined> {
return catchInternal(async () => {
const response = await this.client.get<WikipediaState>('/zim/wikipedia')
const lang = locale || localStorage.getItem('i18nextLng') || 'en'
const response = await this.client.get<WikipediaState>('/zim/wikipedia', { params: { locale: lang } })
return response.data
})()
}
async selectWikipedia(
optionId: string
optionId: string,
locale?: string
): Promise<{ success: boolean; jobId?: string; message?: string } | undefined> {
return catchInternal(async () => {
const lang = locale || localStorage.getItem('i18nextLng') || 'en'
const response = await this.client.post<{
success: boolean
jobId?: string
message?: string
}>('/zim/wikipedia/select', { optionId })
}>('/zim/wikipedia/select', { optionId, locale: lang })
return response.data
})()
}

21
admin/inertia/lib/i18n.ts Normal file
View File

@ -0,0 +1,21 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from '../locales/en.json'
import de from '../locales/de.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
de: { translation: de },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
})
export default i18n

View File

@ -0,0 +1,798 @@
{
"layout": {
"commandCenter": "Kommandozentrale",
"backToHome": "Zurück zur Startseite"
},
"home": {
"title": "Kommandozentrale",
"updateAvailable": "Ein Update ist verfügbar für Project N.O.M.A.D.!",
"goToSettings": "Zu den Einstellungen",
"startHere": "Hier starten!",
"poweredBy": "Unterstützt von {{name}}",
"accessApp": "{{name}} öffnen",
"maps": {
"label": "Karten",
"description": "Offline-Karten anzeigen"
},
"easySetup": {
"label": "Schnelleinrichtung",
"description": "Nicht sicher, wo du anfangen sollst? Nutze den Einrichtungsassistenten, um dein N.O.M.A.D. schnell zu konfigurieren!"
},
"installApps": {
"label": "Apps installieren",
"description": "Deine Lieblings-App fehlt? Installiere sie hier!"
},
"docs": {
"label": "Dokumentation",
"description": "Project N.O.M.A.D. Anleitungen und Handbücher lesen"
},
"settings": {
"label": "Einstellungen",
"description": "Deine N.O.M.A.D. Einstellungen konfigurieren"
}
},
"maps": {
"title": "Karten",
"alertNoBaseAssets": "Die Basis-Kartendaten wurden noch nicht installiert. Bitte lade sie zuerst herunter, um die Kartenfunktion zu aktivieren.",
"alertNoRegions": "Es wurden noch keine Kartenregionen heruntergeladen. Bitte lade einige Regionen herunter, um die Kartenfunktion zu aktivieren.",
"manageRegions": "Kartenregionen verwalten",
"goToSettings": "Zu den Karteneinstellungen"
},
"easySetup": {
"title": "Einrichtungsassistent",
"capabilities": {
"information": {
"name": "Informationsbibliothek",
"description": "Offline-Zugriff auf Wikipedia, medizinische Referenzen, Anleitungen und Enzyklopädien",
"features": {
"wikipedia": "Komplettes Wikipedia offline",
"medical": "Medizinische Referenzen und Erste-Hilfe-Anleitungen",
"diy": "DIY-Reparaturanleitungen und Praxistipps",
"gutenberg": "Project Gutenberg Bücher und Literatur"
}
},
"education": {
"name": "Lernplattform",
"description": "Interaktive Lernplattform mit Videokursen und Übungen",
"features": {
"khan": "Khan Academy Mathematik- und Naturwissenschaftskurse",
"k12": "Lehrplaninhalte für Klasse 1-12",
"exercises": "Interaktive Übungen und Quizze",
"progress": "Fortschrittsverfolgung für Lernende"
}
},
"ai": {
"description": "Lokaler KI-Chat, der vollständig auf deiner Hardware läuft kein Internet erforderlich",
"features": {
"private": "Private Gespräche, die dein Gerät nie verlassen",
"offline": "Nach der Einrichtung keine Internetverbindung nötig",
"questions": "Stelle Fragen, lass dir beim Schreiben helfen, sammle Ideen",
"local": "Läuft auf deiner eigenen Hardware mit lokalen KI-Modellen"
}
},
"notes": {
"name": "Notizen",
"description": "Einfache Notiz-App mit lokalem Speicher",
"features": {
"markdown": "Markdown-Unterstützung",
"local": "Alle Notizen lokal gespeichert",
"noAccount": "Kein Konto erforderlich"
}
},
"datatools": {
"name": "Daten-Tools",
"description": "Schweizer Taschenmesser für Datenkodierung, Verschlüsselung und Analyse",
"features": {
"encode": "Daten kodieren/dekodieren (Base64, Hex, etc.)",
"encryption": "Verschlüsselungs- und Hashing-Tools",
"conversion": "Datenformatkonvertierung"
}
}
},
"installed": "Installiert",
"steps": {
"apps": "Apps",
"maps": "Karten",
"content": "Inhalte",
"review": "Überprüfen"
},
"step1": {
"heading": "Was soll NOMAD für dich tun?",
"subheading": "Wähle die Funktionen, die du brauchst. Du kannst später jederzeit weitere hinzufügen.",
"allInstalled": "Alle verfügbaren Funktionen sind bereits installiert!",
"manageApps": "Apps verwalten",
"coreCapabilities": "Kernfunktionen",
"additionalTools": "Zusätzliche Tools"
},
"step2": {
"heading": "Kartenregionen auswählen",
"subheading": "Wähle Kartenregionen zum Herunterladen für die Offline-Nutzung. Du kannst später jederzeit weitere Regionen herunterladen.",
"noCollections": "Derzeit keine Kartensammlungen verfügbar."
},
"step3": {
"heading": "Inhalte auswählen",
"subtextBoth": "Wähle KI-Modelle und Inhaltskategorien für die Offline-Nutzung.",
"subtextAi": "Wähle KI-Modelle zum Herunterladen für die Offline-Nutzung.",
"subtextInfo": "Wähle Inhaltskategorien für Offline-Wissen.",
"subtextDefault": "Konfiguriere Inhalte für deine ausgewählten Funktionen.",
"aiModels": "KI-Modelle",
"aiModelsSubtext": "Wähle Modelle zum Herunterladen für Offline-KI",
"size": "Größe: {{size}}",
"noModels": "Derzeit keine empfohlenen KI-Modelle verfügbar.",
"additionalContent": "Zusätzliche Inhalte",
"additionalContentSubtext": "Kuratierte Sammlungen für Offline-Referenz",
"noContentCapabilities": "Keine inhaltsbasierten Funktionen ausgewählt. Du kannst diesen Schritt überspringen oder zurückgehen, um Funktionen auszuwählen, die Inhalte benötigen."
},
"step4": {
"heading": "Deine Auswahl überprüfen",
"subheading": "Überprüfe deine Auswahl, bevor du den Einrichtungsprozess startest.",
"noSelections": "Keine Auswahl getroffen",
"noSelectionsMessage": "Du hast nichts zum Installieren oder Herunterladen ausgewählt. Du kannst zurückgehen, um eine Auswahl zu treffen, oder zur Startseite zurückkehren.",
"capabilitiesToInstall": "Zu installierende Funktionen",
"mapCollections": "Kartensammlungen zum Herunterladen ({{count}})",
"contentCategories": "Inhaltskategorien ({{count}})",
"files": "{{count}} Dateien",
"wikipedia": "Wikipedia",
"noDownload": "Kein Download",
"aiModelsToDownload": "KI-Modelle zum Herunterladen ({{count}})",
"readyToStart": "Bereit zum Starten",
"readyToStartMessage": "Klicke auf 'Einrichtung abschließen', um mit der Installation von Apps und dem Herunterladen von Inhalten zu beginnen. Dies kann je nach Internetverbindung und Größe der Downloads einige Zeit dauern."
},
"noInternet": "Keine Internetverbindung",
"noInternetMessage": "Du benötigst eine Internetverbindung, um fortzufahren. Bitte stelle eine Verbindung her und versuche es erneut.",
"noInternetSetup": "Du benötigst eine Internetverbindung, um die Einrichtung abzuschließen.",
"setupComplete": "Einrichtungsassistent abgeschlossen! Deine Auswahl wird verarbeitet.",
"setupError": "Ein Fehler ist bei der Einrichtung aufgetreten. Einige Elemente wurden möglicherweise nicht verarbeitet.",
"back": "Zurück",
"next": "Weiter",
"cancelGoHome": "Abbrechen & zur Startseite",
"completeSetup": "Einrichtung abschließen",
"selectionSummary": "{{capabilities}} {{capabilityLabel}}, {{maps}} Kartenregion{{mapPlural}}, {{categories}} Inhalt{{categoryLabel}}, {{models}} KI-Modell{{modelPlural}} ausgewählt",
"capability": "Funktion",
"capabilities": "Funktionen"
},
"apps": {
"title": "App-Einstellungen",
"heading": "Apps",
"description": "Verwalte die Anwendungen, die in deiner Project N.O.M.A.D. Instanz verfügbar sind. Nächtliche Update-Prüfungen erkennen automatisch neue Versionen dieser Apps.",
"checkForUpdates": "Nach Updates suchen",
"noInternetUpdates": "Du benötigst eine Internetverbindung, um nach Updates zu suchen.",
"failedDispatchUpdate": "Update-Prüfung konnte nicht gestartet werden",
"failedCheckUpdates": "Fehler beim Prüfen auf Updates: {{error}}",
"installService": "Dienst installieren?",
"install": "Installieren",
"cancel": "Abbrechen",
"installConfirm": "Möchtest du {{name}} wirklich installieren? Der Dienst wird gestartet und in deiner Project N.O.M.A.D. Instanz verfügbar gemacht. Dies kann einige Zeit dauern.",
"noInternetInstall": "Du benötigst eine Internetverbindung, um Dienste zu installieren.",
"installInternalError": "Ein interner Fehler ist beim Installieren des Dienstes aufgetreten.",
"failedInstall": "Dienst konnte nicht installiert werden: {{error}}",
"affectInternalError": "Ein interner Fehler ist beim Verarbeiten des Dienstes aufgetreten.",
"failedAction": "{{action}} des Dienstes fehlgeschlagen: {{error}}",
"forceReinstall": "Neuinstallation erzwingen",
"forceReinstallTitle": "Neuinstallation erzwingen?",
"forceReinstallConfirm": "Möchtest du {{name}} wirklich neu installieren? Dies wird <strong>ALLE DATEN LÖSCHEN</strong> für diesen Dienst und kann nicht rückgängig gemacht werden. Tue dies nur, wenn der Dienst nicht richtig funktioniert und andere Fehlerbehebungsschritte fehlgeschlagen sind.",
"forceReinstallInternalError": "Ein interner Fehler ist bei der erzwungenen Neuinstallation aufgetreten.",
"failedForceReinstall": "Erzwungene Neuinstallation fehlgeschlagen: {{error}}",
"open": "Öffnen",
"update": "Aktualisieren",
"stopService": "Dienst stoppen?",
"startService": "Dienst starten?",
"stop": "Stoppen",
"start": "Starten",
"stopConfirm": "Möchtest du {{name}} wirklich stoppen?",
"startConfirm": "Möchtest du {{name}} wirklich starten?",
"restartService": "Dienst neustarten?",
"restart": "Neustarten",
"restartConfirm": "Möchtest du {{name}} wirklich neustarten?",
"columns": {
"name": "Name",
"location": "Standort",
"installed": "Installiert",
"version": "Version",
"actions": "Aktionen"
}
},
"system": {
"title": "Systeminformationen",
"heading": "Systeminformationen",
"subtitle": "Echtzeitüberwachung und Diagnose • Zuletzt aktualisiert: {{time}} • Aktualisierung alle 30 Sekunden",
"highMemory": "Sehr hohe Speicherauslastung erkannt",
"highMemoryMessage": "Die Systemspeicherauslastung übersteigt 90%. Leistungseinbußen können auftreten.",
"resourceUsage": "Ressourcenauslastung",
"cpuUsage": "CPU-Auslastung",
"memoryUsage": "Speicherauslastung",
"swapUsage": "Swap-Auslastung",
"systemDetails": "Systemdetails",
"operatingSystem": "Betriebssystem",
"distribution": "Distribution",
"kernelVersion": "Kernel-Version",
"architecture": "Architektur",
"hostname": "Hostname",
"platform": "Plattform",
"processor": "Prozessor",
"manufacturer": "Hersteller",
"brand": "Marke",
"cores": "Kerne",
"coresCount": "{{count}} Kerne",
"physicalCores": "Physische Kerne",
"virtualization": "Virtualisierung",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"gpuNotAccessible": "GPU für KI-Assistent nicht zugänglich",
"gpuNotAccessibleMessage": "Dein System hat eine NVIDIA GPU, aber der KI-Assistent kann nicht darauf zugreifen. KI läuft nur auf der CPU, was deutlich langsamer ist.",
"fixReinstallAi": "Lösung: KI-Assistent neu installieren",
"graphics": "Grafik",
"gpuModel": "GPU {{index}} Modell",
"gpuVendor": "GPU {{index}} Hersteller",
"gpuVram": "GPU {{index}} VRAM",
"na": "N/V",
"memoryAllocation": "Speicherzuweisung",
"totalRam": "Gesamt-RAM",
"usedRam": "Belegter RAM",
"availableRam": "Verfügbarer RAM",
"utilized": "{{percent}}% Ausgelastet",
"storageDevices": "Speichergeräte",
"normal": "Normal",
"warningUsageHigh": "Warnung Hohe Auslastung",
"criticalDiskFull": "Kritisch Festplatte fast voll",
"noStorageDevices": "Keine Speichergeräte erkannt",
"systemStatus": "Systemstatus",
"systemUptime": "Systemlaufzeit",
"cpuCores": "CPU-Kerne",
"reinstallAi": "KI-Assistent neu installieren?",
"reinstallConfirm": "Neu installieren",
"reinstallAiMessage": "Der KI-Assistent-Container wird mit GPU-Unterstützung neu erstellt. Deine heruntergeladenen Modelle bleiben erhalten. Der Dienst ist während der Neuinstallation kurzzeitig nicht verfügbar.",
"reinstallSuccess": "KI-Assistent wird mit GPU-Unterstützung neu installiert. Diese Seite wird in Kürze neu geladen.",
"reinstallFailed": "Neuinstallation fehlgeschlagen: {{error}}"
},
"common": {
"noRecordsFound": "Keine Einträge gefunden",
"activeDownloads": "Aktive Downloads",
"noActiveDownloads": "Keine aktiven Downloads",
"storage": "Speicherplatz",
"selected": "ausgewählt",
"current": "Aktuell ({{size}})",
"selectedAddition": "Ausgewählt (+{{size}})",
"exceedsSpace": "Überschreitet verfügbaren Speicherplatz um {{size}}",
"willRemainFree": "{{size}} bleiben frei",
"documentation": "Dokumentation",
"settings": "Einstellungen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"backToHome": "Zurück zur Startseite",
"closeSidebar": "Seitenleiste schließen",
"loading": "Laden",
"loadingEllipsis": "Laden...",
"downloadComplete": "Download abgeschlossen",
"estSpeed": "Geschätzte Geschwindigkeit: {{speed}}",
"installationActivity": "Installationsaktivität",
"items": "Elemente: {{count}} | Größe: {{size}}",
"allItemsDownloaded": "Alle Elemente heruntergeladen",
"activeModelDownloads": "Aktive Modell-Downloads",
"noActiveModelDownloads": "Keine aktiven Modell-Downloads",
"processingQueue": "Verarbeitungswarteschlange",
"noFilesProcessing": "Es werden derzeit keine Dateien verarbeitet",
"delete": "Löschen",
"upload": "Hochladen"
},
"settingsNav": {
"apps": "Apps",
"benchmark": "Benchmark",
"contentExplorer": "Inhalte-Explorer",
"contentManager": "Inhaltsverwaltung",
"mapsManager": "Kartenverwaltung",
"serviceLogs": "Dienst-Protokolle & Metriken",
"checkForUpdates": "Nach Updates suchen",
"system": "System",
"legalNotices": "Rechtliche Hinweise",
"supportProject": "Projekt unterstützen"
},
"about": {
"title": "Über",
"hello": "Hallo von Über!"
},
"docs": {
"title": "Dokumentation"
},
"easySetupComplete": {
"title": "Einrichtungsassistent abgeschlossen",
"noInternet": "Keine Internetverbindung",
"noInternetMessage": "Es sieht so aus, als ob du nicht mit dem Internet verbunden bist. Die Installation von Apps und das Herunterladen von Inhalten erfordert eine Internetverbindung.",
"installActivity": "App-Installationsaktivität",
"runningInBackground": "Läuft im Hintergrund",
"runningInBackgroundMessage": "Du kannst diese Seite jederzeit verlassen deine App-Installationen und Downloads werden im Hintergrund fortgesetzt! Bitte beachte, dass die Informationsbibliothek (falls installiert) möglicherweise nicht verfügbar ist, bis alle ersten Downloads abgeschlossen sind.",
"goToHome": "Zur Startseite"
},
"errors": {
"notFound": "Seite nicht gefunden",
"notFoundMessage": "Diese Seite existiert nicht.",
"serverError": "Serverfehler"
},
"mapsManager": {
"title": "Kartenverwaltung",
"heading": "Kartenverwaltung",
"description": "Verwalte deine gespeicherten Kartendateien und entdecke neue Regionen!",
"baseAssetsAlert": "Die Basis-Kartendaten wurden noch nicht installiert. Bitte lade sie zuerst herunter, um die Kartenfunktion zu aktivieren.",
"downloadBaseAssets": "Basisdaten herunterladen",
"baseAssetsSuccess": "Basis-Kartendaten erfolgreich heruntergeladen.",
"baseAssetsError": "Beim Herunterladen der Basis-Kartendaten ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"baseAssetsUnknownError": "Beim Herunterladen der Basisdaten ist ein unbekannter Fehler aufgetreten.",
"curatedMapRegions": "Kuratierte Kartenregionen",
"forceRefreshCollections": "Sammlungen aktualisieren erzwingen",
"refreshSuccess": "Kartensammlungen erfolgreich aktualisiert.",
"noCuratedCollections": "Keine kuratierten Sammlungen verfügbar.",
"storedMapFiles": "Gespeicherte Kartendateien",
"downloadCustomMapFile": "Benutzerdefinierte Kartendatei herunterladen",
"downloadMapFileTitle": "Kartendatei herunterladen",
"downloadModalDescription": "Gib die URL der Kartenregionsdatei ein, die du herunterladen möchtest. Die URL muss öffentlich erreichbar sein und auf .pmtiles enden. Eine Vorabprüfung wird durchgeführt, um die Verfügbarkeit, den Typ und die ungefähre Größe der Datei zu überprüfen.",
"downloadModalPlaceholder": "Download-URL eingeben...",
"preflightRunning": "Vorabprüfung für URL wird ausgeführt: {{url}}",
"preflightPassed": "Vorabprüfung bestanden. Dateiname: {{filename}}, Größe: {{size}} MB",
"preflightUnknownError": "Bei der Vorabprüfung ist ein unbekannter Fehler aufgetreten.",
"preflightFailed": "Vorabprüfung fehlgeschlagen: {{error}}",
"confirmDelete": "Löschen bestätigen?",
"confirmDeleteMessage": "Möchtest du {{name}} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete": "Löschen",
"cancel": "Abbrechen",
"confirmDownload": "Download bestätigen?",
"confirmDownloadMessage": "Möchtest du <strong>{{name}}</strong> wirklich herunterladen? Es kann je nach Dateigröße und Internetverbindung einige Zeit dauern, bis es verfügbar ist.",
"download": "Herunterladen",
"allResourcesDownloaded": "Alle Ressourcen in der Sammlung \"{{name}}\" wurden bereits heruntergeladen.",
"downloadQueued": "Download für Sammlung \"{{name}}\" wurde in die Warteschlange gestellt.",
"customDownloadQueued": "Download wurde in die Warteschlange gestellt.",
"columns": {
"name": "Name",
"actions": "Aktionen"
}
},
"contentManager": {
"title": "Inhaltsverwaltung",
"heading": "Inhaltsverwaltung",
"description": "Verwalte deine gespeicherten Inhaltsdateien.",
"kiwixNotInstalled": "Die Kiwix-Anwendung ist nicht installiert. Bitte installiere sie, um heruntergeladene ZIM-Dateien anzuzeigen",
"confirmDelete": "Löschen bestätigen?",
"confirmDeleteMessage": "Möchtest du {{name}} wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete": "Löschen",
"cancel": "Abbrechen",
"columns": {
"title": "Titel",
"summary": "Zusammenfassung",
"actions": "Aktionen"
}
},
"contentExplorer": {
"title": "Inhalte-Explorer",
"heading": "Inhalte-Explorer",
"description": "Durchsuche und lade Inhalte zum Offline-Lesen herunter!",
"noInternet": "Keine Internetverbindung. Möglicherweise kannst du keine Dateien herunterladen.",
"kiwixNotInstalled": "Die Kiwix-Anwendung ist nicht installiert. Bitte installiere sie, um heruntergeladene Inhaltsdateien anzuzeigen.",
"curatedContent": "Kuratierte Inhalte",
"forceRefreshCollections": "Sammlungen aktualisieren erzwingen",
"refreshSuccess": "Inhaltssammlungen erfolgreich aktualisiert.",
"additionalContent": "Zusätzliche Inhalte",
"additionalContentSubtext": "Kuratierte Sammlungen für Offline-Referenz",
"noCuratedCategories": "Keine kuratierten Inhaltskategorien verfügbar.",
"browseKiwixLibrary": "Kiwix-Bibliothek durchsuchen",
"searchPlaceholder": "Verfügbare ZIM-Dateien suchen...",
"confirmDownload": "Download bestätigen?",
"confirmDownloadMessage": "Möchtest du <strong>{{title}}</strong> wirklich herunterladen? Es kann je nach Dateigröße und Internetverbindung einige Zeit dauern, bis es verfügbar ist. Die Kiwix-Anwendung wird nach Abschluss des Downloads neu gestartet.",
"download": "Herunterladen",
"cancel": "Abbrechen",
"tierDownloadStarted": "Download von \"{{name}}\" gestartet",
"tierDownloadError": "Beim Starten der Downloads ist ein Fehler aufgetreten.",
"wikipediaRemoved": "Wikipedia erfolgreich entfernt",
"wikipediaDownloadStarted": "Wikipedia-Download gestartet",
"wikipediaSelectionFailed": "Wikipedia-Auswahl konnte nicht geändert werden",
"wikipediaSelectionError": "Beim Ändern der Wikipedia-Auswahl ist ein Fehler aufgetreten",
"columns": {
"size": "Größe",
"title": "Titel",
"author": "Autor",
"summary": "Zusammenfassung",
"updated": "Aktualisiert",
"actions": "Aktionen"
},
"tierModal": {
"selectTierDescription": "Wähle eine Stufe basierend auf deiner Speicherkapazität und deinen Bedürfnissen. Höhere Stufen enthalten alle Inhalte niedrigerer Stufen.",
"includes": "enthält {{name}}",
"additionalResources": "{{count}} zusätzliche Ressource",
"additionalResources_plural": "{{count}} zusätzliche Ressourcen",
"plusEverythingIn": "plus alles aus {{name}}",
"resourcesIncluded": "{{count}} Ressource enthalten",
"resourcesIncluded_plural": "{{count}} Ressourcen enthalten",
"changeNote": "Du kannst deine Auswahl jederzeit ändern. Klicke auf Absenden, um deine Wahl zu bestätigen.",
"submit": "Absenden"
},
"wikipedia": {
"title": "Wikipedia",
"selectPackage": "Wähle dein bevorzugtes Wikipedia-Paket",
"downloading": "Wikipedia wird heruntergeladen... Bei größeren Paketen kann dies eine Weile dauern.",
"installed": "Installiert",
"selected": "Ausgewählt",
"downloadingBadge": "Wird heruntergeladen",
"noDownload": "Kein Download",
"removeWikipedia": "Wikipedia entfernen",
"downloadSelected": "Auswahl herunterladen"
},
"categoryCard": {
"tiersAvailable": "{{count}} Stufen verfügbar",
"clickToChoose": "Klicke zum Auswählen",
"size": "Größe: {{min}} - {{max}}"
}
},
"models": {
"title": "{{name}} Einstellungen",
"description": "Verwalte einfach die Einstellungen und installierten Modelle von {{name}}. Wir empfehlen, zuerst mit kleineren Modellen zu beginnen, um zu sehen, wie sie auf deinem System laufen, bevor du zu größeren übergehst.",
"notInstalled": "Die Abhängigkeiten von {{name}} sind nicht installiert. Bitte installiere sie, um KI-Modelle zu verwalten.",
"gpuNotAccessible": "GPU nicht zugänglich",
"gpuNotAccessibleMessage": "Dein System hat eine NVIDIA GPU, aber {{name}} kann nicht darauf zugreifen. KI läuft nur auf der CPU, was deutlich langsamer ist.",
"fixReinstall": "Lösung: {{name}} neu installieren",
"reinstallTitle": "KI-Assistent neu installieren?",
"reinstallMessage": "Der {{name}}-Container wird mit GPU-Unterstützung neu erstellt. Deine heruntergeladenen Modelle bleiben erhalten. Der Dienst ist während der Neuinstallation kurzzeitig nicht verfügbar.",
"reinstallSuccess": "{{name}} wird mit GPU-Unterstützung neu installiert. Diese Seite wird in Kürze neu geladen.",
"reinstallFailed": "Neuinstallation fehlgeschlagen: {{error}}",
"reinstall": "Neu installieren",
"cancel": "Abbrechen",
"settings": "Einstellungen",
"chatSuggestions": "Chat-Vorschläge",
"chatSuggestionsDescription": "KI-generierte Gesprächsstarter in der Chat-Oberfläche anzeigen",
"assistantName": "Assistentenname",
"assistantNameHelp": "Gib deinem KI-Assistenten einen benutzerdefinierten Namen, der in der Chat-Oberfläche und anderen Bereichen der Anwendung verwendet wird.",
"assistantNamePlaceholder": "KI-Assistent",
"modelsHeading": "Modelle",
"searchPlaceholder": "Sprachmodelle suchen..",
"refreshModels": "Modelle aktualisieren",
"refreshSuccess": "Modellliste von Remote aktualisiert.",
"loadMore": "Mehr laden",
"deleteModelTitle": "Modell löschen?",
"deleteModelMessage": "Möchtest du dieses Modell wirklich löschen? Du musst es erneut herunterladen, wenn du es in Zukunft verwenden möchtest.",
"delete": "Löschen",
"install": "Installieren",
"downloadInitiated": "Modell-Download für {{name}} gestartet. Es kann einige Zeit dauern, bis er abgeschlossen ist.",
"downloadError": "Beim Installieren des Modells {{name}} ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"deleteSuccess": "Modell gelöscht: {{name}}.",
"deleteError": "Beim Löschen des Modells {{name}} ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"settingUpdated": "Einstellung erfolgreich aktualisiert.",
"settingUpdateError": "Beim Aktualisieren der Einstellung ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"columns": {
"name": "Name",
"estimatedPulls": "Geschätzte Downloads",
"lastUpdated": "Zuletzt aktualisiert",
"tag": "Tag",
"inputType": "Eingabetyp",
"contextSize": "Kontextgröße",
"modelSize": "Modellgröße",
"action": "Aktion"
}
},
"update": {
"title": "Systemaktualisierung",
"heading": "Systemaktualisierung",
"description": "Halte deine Project N.O.M.A.D. Instanz mit den neuesten Funktionen und Verbesserungen auf dem aktuellen Stand.",
"updateFailed": "Aktualisierung fehlgeschlagen",
"containerRestarting": "Container wird neu gestartet",
"containerRestartingMessage": "Der Admin-Container wird neu gestartet. Diese Seite wird automatisch neu geladen, wenn die Aktualisierung abgeschlossen ist.",
"connectionLost": "Verbindung vorübergehend unterbrochen (erwartet)",
"connectionLostMessage": "Möglicherweise siehst du Fehlermeldungen, während das Backend während der Aktualisierung neu startet. Dies ist völlig normal und erwartet. Die Verbindung sollte in Kürze wiederhergestellt werden.",
"updateAvailable": "Update verfügbar",
"systemUpToDate": "System ist aktuell",
"newVersionAvailable": "Eine neue Version ({{version}}) ist für deine Project N.O.M.A.D. Instanz verfügbar.",
"runningLatest": "Dein System läuft mit der neuesten Version!",
"preparingUpdate": "Update wird vorbereitet",
"currentVersion": "Aktuelle Version",
"latestVersion": "Neueste Version",
"startUpdate": "Update starten",
"noUpdateAvailable": "Kein Update verfügbar",
"checkAgain": "Erneut prüfen",
"percentComplete": "{{percent}}% abgeschlossen",
"whatHappens": "Was passiert bei einem Update?",
"step1Title": "Neueste Images herunterladen",
"step1Description": "Lädt die neuesten Docker-Images für alle Kerncontainer herunter",
"step2Title": "Container neu erstellen",
"step2Description": "Stoppt und erstellt alle Kerncontainer sicher mit den neuen Images neu",
"step3Title": "Automatisches Neuladen",
"step3Description": "Diese Seite wird automatisch neu geladen, wenn das Update abgeschlossen ist",
"viewUpdateLogs": "Update-Protokolle anzeigen",
"backupReminder": "Backup-Erinnerung",
"backupReminderMessage": "Obwohl Updates sicher konzipiert sind, wird empfohlen, kritische Daten vor dem Fortfahren zu sichern.",
"temporaryDowntime": "Vorübergehende Ausfallzeit",
"temporaryDowntimeMessage": "Dienste sind während des Update-Prozesses kurzzeitig nicht verfügbar. Dies dauert in der Regel 2-5 Minuten, abhängig von deiner Internetverbindung.",
"earlyAccess": "Frühzeitiger Zugang",
"enableEarlyAccess": "Frühzeitigen Zugang aktivieren",
"enableEarlyAccessDescription": "Erhalte Release-Candidate-Versionen (RC), bevor sie offiziell veröffentlicht werden. Hinweis: RC-Versionen können Fehler enthalten und werden nicht für Umgebungen empfohlen, in denen Stabilität und Datenintegrität kritisch sind.",
"contentUpdates": "Inhaltsaktualisierungen",
"contentUpdatesDescription": "Prüfe, ob neuere Versionen deiner installierten ZIM-Dateien und Karten verfügbar sind.",
"checkForContentUpdates": "Nach Inhaltsaktualisierungen suchen",
"updateCheckIssue": "Problem bei der Update-Prüfung",
"allContentUpToDate": "Alle Inhalte aktuell",
"allContentUpToDateMessage": "Alle deine installierten Inhalte laufen mit der neuesten verfügbaren Version.",
"updatesAvailable": "{{count}} Update(s) verfügbar",
"updateAll": "Alle aktualisieren ({{count}})",
"updateButton": "Aktualisieren",
"startedUpdates": "{{count}} Update(s) gestartet",
"failedUpdates": "{{count}} Update(s) konnten nicht gestartet werden",
"failedToApplyUpdates": "Updates konnten nicht angewendet werden",
"updateStarted": "Update für {{id}} gestartet",
"failedToStartUpdate": "Update konnte nicht gestartet werden",
"failedToStartUpdateFor": "Update für {{id}} konnte nicht gestartet werden",
"failedToCheckUpdates": "Inhaltsaktualisierungen konnten nicht geprüft werden",
"lastChecked": "Zuletzt geprüft:",
"updateLogs": "Update-Protokolle",
"noLogsAvailable": "Noch keine Protokolle verfügbar...",
"close": "Schließen",
"settingUpdated": "Einstellung erfolgreich aktualisiert.",
"settingUpdateError": "Beim Aktualisieren der Einstellung ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"updateAvailableNotification": "Update verfügbar: {{version}}",
"systemUpToDateNotification": "System ist aktuell",
"failedToCheckForUpdates": "Update-Prüfung fehlgeschlagen",
"subscribeHeading": "Möchtest du über die neuesten Nachrichten von Project N.O.M.A.D. auf dem Laufenden bleiben? Abonniere, um Release-Notes direkt in dein Postfach zu erhalten. Jederzeit abbestellbar.",
"emailPlaceholder": "Deine E-Mail-Adresse",
"subscribe": "Abonnieren",
"privacyNote": "Wir respektieren deine Privatsphäre. Project N.O.M.A.D. wird deine E-Mail niemals an Dritte weitergeben oder dir Spam senden.",
"subscribeSuccess": "Erfolgreich für Release-Notes angemeldet!",
"subscribeFailed": "Anmeldung fehlgeschlagen: {{error}}",
"subscribeError": "Fehler beim Anmelden für Release-Notes: {{error}}",
"columns": {
"title": "Titel",
"type": "Typ",
"version": "Version"
},
"zim": "ZIM",
"map": "Karte"
},
"benchmark": {
"title": "System-Benchmark",
"heading": "System-Benchmark",
"description": "Miss die Leistung deines Servers und vergleiche mit der NOMAD-Community",
"runBenchmark": "Benchmark starten",
"runningBenchmark": "Benchmark wird ausgeführt...",
"benchmarkFailed": "Benchmark fehlgeschlagen",
"aiRequired": "{{name}} erforderlich",
"aiRequiredMessage": "Der vollständige Benchmark erfordert die Installation von {{name}}. Installiere es, um deine vollständige NOMAD-Leistung zu messen und Ergebnisse mit der Community zu teilen.",
"goToApps": "Zu Apps gehen, um {{name}} zu installieren →",
"benchmarkDescription": "Führe einen Benchmark durch, um die CPU-, Arbeitsspeicher-, Festplatten- und KI-Inferenzleistung deines Systems zu messen. Der Benchmark dauert etwa 2-5 Minuten.",
"runFullBenchmark": "Vollständigen Benchmark starten",
"systemOnly": "Nur System",
"aiOnly": "Nur KI",
"aiNotInstalledNote": "{{name}} ist nicht installiert.",
"installIt": "Installieren",
"aiNotInstalledSuffix": "um vollständige Benchmarks auszuführen und Ergebnisse mit der Community zu teilen.",
"aiOnlyTooltip": "{{name}} muss installiert sein, um den KI-Benchmark auszuführen",
"nomadScore": "NOMAD-Punktzahl",
"outOf100": "von 100",
"nomadScoreDescription": "Deine NOMAD-Punktzahl ist ein gewichteter Gesamtwert aller Benchmark-Ergebnisse.",
"shareWithCommunity": "Mit Community teilen",
"shareDescription": "Teile deinen Benchmark auf der Community-Bestenliste. Wähle ein Builder-Tag, um deinen Platz zu beanspruchen, oder teile anonym.",
"yourBuilderTag": "Dein Builder-Tag",
"shareAnonymously": "Anonym teilen (kein Builder-Tag auf der Bestenliste angezeigt)",
"submitting": "Wird eingereicht...",
"shareButton": "Mit Community teilen",
"submissionFailed": "Einreichung fehlgeschlagen",
"alreadySubmitted": "Ein Benchmark für dieses System mit der gleichen oder höheren Punktzahl wurde bereits eingereicht.",
"partialBenchmark": "Teilweiser Benchmark",
"partialBenchmarkMessage": "Dieser {{type}}-Benchmark kann nicht mit der Community geteilt werden. Führe einen vollständigen Benchmark mit installiertem {{name}} durch, um deine Ergebnisse zu teilen.",
"sharedWithCommunity": "Mit Community geteilt",
"sharedMessage": "Dein Benchmark wurde auf der Community-Bestenliste eingereicht. Danke für deinen Beitrag!",
"viewLeaderboard": "Bestenliste anzeigen →",
"systemPerformance": "Systemleistung",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"diskRead": "Festplatte Lesen",
"diskWrite": "Festplatte Schreiben",
"aiPerformance": "KI-Leistung",
"aiScore": "KI-Punktzahl",
"tokensPerSecond": "Tokens pro Sekunde",
"tokensPerSecondTooltip": "Wie schnell die KI Text generiert. Höher ist besser. 30+ Tokens/Sek. fühlt sich reaktionsschnell an, 60+ fühlt sich sofortig an.",
"timeToFirstToken": "Zeit bis zum ersten Token",
"timeToFirstTokenTooltip": "Wie schnell die KI nach dem Senden einer Nachricht antwortet. Niedriger ist besser. Unter 500ms fühlt sich sofortig an.",
"noAIData": "Keine KI-Benchmark-Daten",
"noAIDataMessage": "Führe einen vollständigen Benchmark oder einen reinen KI-Benchmark durch, um die KI-Inferenzleistung zu messen.",
"hardwareInformation": "Hardware-Informationen",
"processor": "Prozessor",
"systemLabel": "System",
"modelLabel": "Modell",
"cores": "Kerne",
"threads": "Threads",
"ram": "RAM",
"diskType": "Festplattentyp",
"gpu": "GPU",
"notDetected": "Nicht erkannt",
"benchmarkDetails": "Benchmark-Details",
"benchmarkId": "Benchmark-ID",
"type": "Typ",
"date": "Datum",
"fullBenchmarkId": "Vollständige Benchmark-ID",
"benchmarkType": "Benchmark-Typ",
"runDate": "Ausführungsdatum",
"builderTag": "Builder-Tag",
"notSet": "Nicht gesetzt",
"aiModelUsed": "Verwendetes KI-Modell",
"submittedToRepository": "An Repository übermittelt",
"yes": "Ja",
"no": "Nein",
"repositoryId": "Repository-ID",
"benchmarkHistory": "Benchmark-Verlauf",
"benchmarksRecorded": "{{count}} Benchmark{{plural}} aufgezeichnet",
"shared": "Geteilt",
"score": "Punktzahl",
"rawScores": "Rohwerte",
"cpuScore": "CPU-Punktzahl",
"memoryScore": "Arbeitsspeicher-Punktzahl",
"diskReadScore": "Festplatte-Lesen-Punktzahl",
"diskWriteScore": "Festplatte-Schreiben-Punktzahl",
"aiTokensSec": "KI-Tokens/Sek.",
"aiTimeToFirstToken": "KI-Zeit bis zum ersten Token",
"benchmarkInfo": "Benchmark-Info",
"noBenchmarkResults": "Keine Benchmark-Ergebnisse",
"noBenchmarkResultsMessage": "Führe deinen ersten Benchmark durch, um die Leistungswerte deines Servers zu sehen.",
"progress": {
"starting": "Benchmark wird gestartet... Dies dauert 2-5 Minuten.",
"completed": "Benchmark abgeschlossen!",
"failed": "Benchmark fehlgeschlagen",
"detectingHardware": "Systemhardware wird erkannt...",
"runningCpu": "CPU-Benchmark wird ausgeführt (30s)...",
"runningMemory": "Arbeitsspeicher-Benchmark wird ausgeführt...",
"runningDiskRead": "Festplatte-Lese-Benchmark wird ausgeführt (30s)...",
"runningDiskWrite": "Festplatte-Schreib-Benchmark wird ausgeführt (30s)...",
"downloadingAiModel": "KI-Benchmark-Modell wird heruntergeladen (nur beim ersten Mal)...",
"runningAi": "KI-Inferenz-Benchmark wird ausgeführt...",
"calculatingScore": "NOMAD-Punktzahl wird berechnet..."
},
"stages": {
"starting": "Starten",
"detectingHardware": "Hardware erkennen",
"cpuBenchmark": "CPU-Benchmark",
"memoryBenchmark": "Arbeitsspeicher-Benchmark",
"diskReadTest": "Festplatte-Lese-Test",
"diskWriteTest": "Festplatte-Schreib-Test",
"downloadingAiModel": "KI-Modell herunterladen",
"aiInferenceTest": "KI-Inferenz-Test",
"calculatingScore": "Punktzahl berechnen",
"complete": "Abgeschlossen",
"error": "Fehler"
}
},
"legal": {
"title": "Rechtliche Hinweise",
"heading": "Rechtliche Hinweise",
"licenseAgreement": "Lizenzvereinbarung",
"copyright": "Copyright 2024-2026 Crosstalk Solutions, LLC",
"licenseText1": "Lizenziert unter der Apache-Lizenz, Version 2.0 (die \"Lizenz\"); du darfst diese Datei nur in Übereinstimmung mit der Lizenz verwenden. Du kannst eine Kopie der Lizenz erhalten unter",
"licenseText2": "Sofern nicht durch geltendes Recht vorgeschrieben oder schriftlich vereinbart, wird die unter der Lizenz vertriebene Software \"WIE BESEHEN\" bereitgestellt, OHNE GEWÄHRLEISTUNGEN ODER BEDINGUNGEN JEGLICHER ART, weder ausdrücklich noch stillschweigend. Siehe die Lizenz für die spezifische Sprache, die Berechtigungen und Einschränkungen unter der Lizenz regelt.",
"thirdParty": "Drittanbieter-Software-Zuordnung",
"thirdPartyIntro": "Project N.O.M.A.D. integriert die folgenden Open-Source-Projekte. Wir sind ihren Entwicklern und Communities dankbar:",
"kiwixDescription": "Offline-Wikipedia- und Inhaltsleser (GPL-3.0-Lizenz)",
"kolibriDescription": "Offline-Lernplattform von Learning Equality (MIT-Lizenz)",
"ollamaDescription": "Lokale Laufzeitumgebung für große Sprachmodelle (MIT-Lizenz)",
"cyberchefDescription": "Datenanalyse- und Kodierungs-Toolkit von GCHQ (Apache 2.0-Lizenz)",
"flatnotesDescription": "Selbst gehostete Notiz-Anwendung (MIT-Lizenz)",
"qdrantDescription": "Vektorsuchmaschine für KI-Wissensbasis (Apache 2.0-Lizenz)",
"privacyStatement": "Datenschutzerklärung",
"privacyIntro": "Project N.O.M.A.D. wurde mit Datenschutz als Kernprinzip entwickelt:",
"zeroTelemetry": "Keine Telemetrie:",
"zeroTelemetryText": "N.O.M.A.D. sammelt, überträgt oder speichert keine Nutzungsdaten, Analysen oder Telemetrie.",
"localFirst": "Lokal zuerst:",
"localFirstText": "Alle deine Daten, heruntergeladenen Inhalte, KI-Gespräche und Notizen verbleiben auf deinem Gerät.",
"noAccounts": "Kein Konto erforderlich:",
"noAccountsText": "N.O.M.A.D. funktioniert standardmäßig ohne Benutzerkonten oder Authentifizierung.",
"networkOptional": "Netzwerk optional:",
"networkOptionalText": "Eine Internetverbindung ist nur zum Herunterladen von Inhalten oder Updates erforderlich. Alle installierten Funktionen funktionieren vollständig offline.",
"contentDisclaimer": "Haftungsausschluss für Inhalte",
"contentDisclaimerText1": "Project N.O.M.A.D. bietet Tools zum Herunterladen und Zugreifen auf Inhalte von Drittanbietern, einschließlich Wikipedia, Wikibooks, medizinische Referenzen, Bildungsplattformen und andere öffentlich verfügbare Ressourcen.",
"contentDisclaimerText2": "Crosstalk Solutions, LLC erstellt, kontrolliert, überprüft oder garantiert nicht die Genauigkeit, Vollständigkeit oder Zuverlässigkeit von Drittanbieter-Inhalten. Die Aufnahme von Inhalten stellt keine Empfehlung dar.",
"contentDisclaimerText3": "Benutzer sind dafür verantwortlich, die Angemessenheit und Genauigkeit aller Inhalte zu bewerten, die sie herunterladen und verwenden.",
"medicalDisclaimer": "Haftungsausschluss für medizinische und Notfallinformationen",
"medicalDisclaimerText1": "Einige über N.O.M.A.D. verfügbare Inhalte umfassen medizinische Referenzen, Erste-Hilfe-Anleitungen und Informationen zur Notfallvorsorge. Diese Inhalte werden nur zu allgemeinen Informationszwecken bereitgestellt.",
"medicalDisclaimerText2": "Diese Informationen sind KEIN Ersatz für professionelle medizinische Beratung, Diagnose oder Behandlung.",
"medicalPoint1": "Hole immer den Rat qualifizierter Gesundheitsdienstleister bei Fragen zu medizinischen Zuständen ein.",
"medicalPoint2": "Ignoriere niemals professionellen medizinischen Rat oder verzögere die Suche danach aufgrund von etwas, das du in Offline-Inhalten gelesen hast.",
"medicalPoint3": "Rufe bei einem medizinischen Notfall sofort den Rettungsdienst an, wenn verfügbar.",
"medicalPoint4": "Medizinische Informationen können veraltet sein. Überprüfe kritische Informationen nach Möglichkeit mit aktuellen professionellen Quellen.",
"dataStorage": "Datenspeicherung",
"dataStorageIntro": "Alle mit Project N.O.M.A.D. verbundenen Daten werden lokal auf deinem Gerät gespeichert:",
"installationDirectory": "Installationsverzeichnis:",
"downloadedContent": "Heruntergeladene Inhalte:",
"applicationData": "Anwendungsdaten:",
"applicationDataValue": "In Docker-Volumes auf deinem lokalen System gespeichert",
"dataStorageNote": "Du behältst die volle Kontrolle über deine Daten. Die Deinstallation von N.O.M.A.D. oder das Löschen dieser Verzeichnisse entfernt alle zugehörigen Daten dauerhaft."
},
"updateService": {
"title": "Service aktualisieren",
"update": "Aktualisieren",
"failedToLoadVersions": "Verfügbare Versionen konnten nicht geladen werden",
"updateConfirmation": "<strong>{{name}}</strong> von <code>{{from}}</code> auf <code>{{to}}</code> aktualisieren?",
"dataPreserved": "Deine Daten und Konfiguration bleiben während des Updates erhalten.",
"viewReleaseNotes": "Versionshinweise anzeigen",
"showVersions": "Verfügbare Versionen anzeigen",
"hideVersions": "Verfügbare Versionen ausblenden",
"loadingVersions": "Versionen werden geladen...",
"noVersionsAvailable": "Keine weiteren Versionen verfügbar",
"latest": "Neueste",
"releaseNotes": "Versionshinweise",
"majorVersionWarning": "Es wird nicht empfohlen, auf eine neue Hauptversion zu aktualisieren (z.B. 1.8.2 → 2.0.0), es sei denn, du hast die Kompatibilität mit deiner aktuellen Konfiguration überprüft. Überprüfe immer die Versionshinweise und teste wenn möglich in einer Staging-Umgebung."
},
"chat": {
"newChat": "Neuer Chat",
"noPreviousChats": "Keine vorherigen Chats",
"openInNewTab": "In neuem Tab öffnen",
"modelsAndSettings": "Modelle & Einstellungen",
"knowledgeBase": "Wissensdatenbank",
"clearHistory": "Verlauf löschen",
"startConversation": "Starte eine Unterhaltung",
"interactWithModels": "Interagiere direkt im Command Center mit deinen installierten Sprachmodellen.",
"suggestions": "Vorschläge:",
"thinking": "Denkt nach",
"enableSuggestions": "Brauchst du Inspiration? Aktiviere Chat-Vorschläge in den Einstellungen, um mit Beispiel-Prompts zu beginnen.",
"reasoning": "Überlegung",
"thoughtFor": "{{seconds}}s nachgedacht",
"errorNoResponse": "Entschuldigung, ich konnte keine Antwort generieren.",
"errorProcessing": "Entschuldigung, bei der Verarbeitung deiner Anfrage ist ein Fehler aufgetreten. Bitte versuche es erneut.",
"noModelsInstalled": "Keine Modelle installiert",
"loadingModels": "Modelle werden geladen...",
"model": "Modell:",
"clearAllHistory": "Gesamten Chatverlauf löschen?",
"clearAllConfirm": "Bist du sicher, dass du alle Chat-Sitzungen löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden und alle Unterhaltungen werden dauerhaft gelöscht.",
"clearAll": "Alle löschen",
"typePlaceholder": "Schreibe deine Nachricht an {{name}}... (Umschalt+Eingabe für neue Zeile)",
"ragModelNotInstalled": "Das Modell {{model}} ist nicht installiert. Erwäge es <button>herunterzuladen</button> für eine verbesserte RAG-Retrieval-Leistung.",
"downloadModelTitle": "{{model}} herunterladen?",
"download": "Herunterladen",
"downloadModelDescription": "Dies wird einen Hintergrund-Download-Job für <strong>{{model}}</strong> starten und kann einige Zeit in Anspruch nehmen. Das Modell wird verwendet, um Abfragen für eine verbesserte RAG-Retrieval-Leistung umzuschreiben.",
"modelDownloadQueued": "Modell-Download in Warteschlange gestellt",
"modelDownloadFailed": "Modell-Download konnte nicht in die Warteschlange gestellt werden",
"knowledgeBaseTitle": "Wissensdatenbank",
"uploadSuccess": "Dokument hochgeladen und zur Verarbeitung eingereiht",
"uploadFailed": "Dokument konnte nicht hochgeladen werden",
"fileRemoved": "Datei aus der Wissensdatenbank entfernt.",
"fileDeleteFailed": "Datei konnte nicht gelöscht werden.",
"confirmSync": "Synchronisierung bestätigen?",
"confirmSyncButton": "Synchronisierung bestätigen",
"syncDescription": "Dies durchsucht die Speicherverzeichnisse von NOMAD nach neuen Dateien und reiht sie zur Verarbeitung ein. Dies ist nützlich, wenn du manuell Dateien zum Speicher hinzugefügt hast oder sicherstellen möchtest, dass alles aktuell ist. Dies kann zu einem vorübergehenden Anstieg der Ressourcennutzung führen, wenn neue Dateien gefunden und verarbeitet werden. Bist du sicher, dass du fortfahren möchtest?",
"syncSuccess": "Speicher erfolgreich synchronisiert. Wenn neue Dateien gefunden wurden, wurden sie zur Verarbeitung eingereiht.",
"syncFailed": "Synchronisierung des Speichers fehlgeschlagen",
"whyUpload": "Warum Dokumente in deine Wissensdatenbank hochladen?",
"kbIntegrationTitle": "{{name}} Wissensdatenbank-Integration",
"kbIntegrationDescription": "Wenn du Dokumente in deine Wissensdatenbank hochlädst, verarbeitet und indiziert NOMAD den Inhalt und macht ihn direkt für {{name}} zugänglich. Dadurch kann {{name}} in Unterhaltungen auf deine spezifischen Dokumente verweisen und genauere, personalisierte Antworten basierend auf deinen hochgeladenen Daten liefern.",
"ocrTitle": "Erweiterte Dokumentenverarbeitung mit OCR",
"ocrDescription": "NOMAD verfügt über integrierte optische Zeichenerkennung (OCR), die es ermöglicht, Text aus bildbasierten Dokumenten wie gescannten PDFs oder Fotos zu extrahieren. Das bedeutet, dass NOMAD auch dann den Inhalt verarbeiten und für den KI-Zugriff einbetten kann, wenn deine Dokumente nicht im Standard-Textformat vorliegen.",
"libraryTitle": "Informationsbibliothek-Integration",
"libraryDescription": "NOMAD erkennt und extrahiert automatisch alle Inhalte, die du in deiner Informationsbibliothek speicherst (falls installiert), und macht sie sofort für {{name}} verfügbar ohne zusätzliche Schritte.",
"storedFiles": "Gespeicherte Wissensdatenbank-Dateien",
"syncStorage": "Speicher synchronisieren",
"fileName": "Dateiname",
"removeFromKb": "Aus Wissensdatenbank entfernen?",
"deleting": "Wird gelöscht…"
},
"support": {
"title": "Projekt unterstützen",
"subtitle": "Project NOMAD ist 100 % kostenlos und Open Source — keine Abonnements, keine Paywalls, kein Haken. Wenn du das Projekt unterstützen möchtest, gibt es hier ein paar Möglichkeiten.",
"kofiTitle": "Spendiere uns einen Kaffee",
"kofiDescription": "Jeder Beitrag hilft bei der Finanzierung der Entwicklung, Serverkosten und neuen Inhaltspaketen für NOMAD. Auch eine kleine Spende macht viel aus.",
"kofiButton": "Auf Ko-fi unterstützen",
"rogueTitle": "Brauchst du Hilfe mit deinem Heimnetzwerk?",
"rogueBannerAlt": "Rogue Support — Erobere dein Heimnetzwerk",
"rogueDescription": "Rogue Support ist ein Netzwerk-Beratungsservice für Privatanwender. Stell es dir vor wie Uber für Computernetzwerke — Expertenhilfe, wenn du sie brauchst.",
"rogueButton": "RogueSupport.com besuchen",
"otherTitle": "Weitere Möglichkeiten zu helfen",
"starOnGithub": "Projekt auf GitHub mit einem Stern versehen",
"starOnGithubSuffix": "das hilft mehr Menschen, NOMAD zu entdecken",
"reportBugs": "Fehler melden und Funktionen vorschlagen",
"reportBugsSuffix": "jede Meldung macht NOMAD besser",
"shareNomad": "Teile NOMAD mit jemandem, der es nutzen würde — Mundpropaganda ist das beste Marketing",
"joinDiscord": "Tritt der Discord-Community bei",
"joinDiscordSuffix": "austauschen, dein Setup teilen, anderen Nutzern helfen"
},
"debugInfo": {
"title": "Debug-Informationen",
"close": "Schließen",
"description": "Dies sind nicht-sensible Systeminformationen, die du beim Melden von Problemen teilen kannst. Keine Passwörter, IPs oder API-Schlüssel enthalten.",
"loadFailed": "Debug-Informationen konnten nicht geladen werden. Bitte versuche es erneut.",
"copied": "Kopiert!",
"copyToClipboard": "In Zwischenablage kopieren",
"openGithubIssue": "GitHub-Issue erstellen"
},
"footer": {
"commandCenter": "Project N.O.M.A.D. Command Center v{{version}}",
"debugInfo": "Debug-Informationen"
}
}

View File

@ -0,0 +1,798 @@
{
"layout": {
"commandCenter": "Command Center",
"backToHome": "Back to Home"
},
"home": {
"title": "Command Center",
"updateAvailable": "An update is available for Project N.O.M.A.D.!",
"goToSettings": "Go to Settings",
"startHere": "Start here!",
"poweredBy": "Powered by {{name}}",
"accessApp": "Access the {{name}} application",
"maps": {
"label": "Maps",
"description": "View offline maps"
},
"easySetup": {
"label": "Easy Setup",
"description": "Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!"
},
"installApps": {
"label": "Install Apps",
"description": "Not seeing your favorite app? Install it here!"
},
"docs": {
"label": "Docs",
"description": "Read Project N.O.M.A.D. manuals and guides"
},
"settings": {
"label": "Settings",
"description": "Configure your N.O.M.A.D. settings"
}
},
"maps": {
"title": "Maps",
"alertNoBaseAssets": "The base map assets have not been installed. Please download them first to enable map functionality.",
"alertNoRegions": "No map regions have been downloaded yet. Please download some regions to enable map functionality.",
"manageRegions": "Manage Map Regions",
"goToSettings": "Go to Map Settings"
},
"easySetup": {
"title": "Easy Setup Wizard",
"capabilities": {
"information": {
"name": "Information Library",
"description": "Offline access to Wikipedia, medical references, how-to guides, and encyclopedias",
"features": {
"wikipedia": "Complete Wikipedia offline",
"medical": "Medical references and first aid guides",
"diy": "DIY repair guides and how-to content",
"gutenberg": "Project Gutenberg books and literature"
}
},
"education": {
"name": "Education Platform",
"description": "Interactive learning platform with video courses and exercises",
"features": {
"khan": "Khan Academy math and science courses",
"k12": "K-12 curriculum content",
"exercises": "Interactive exercises and quizzes",
"progress": "Progress tracking for learners"
}
},
"ai": {
"description": "Local AI chat that runs entirely on your hardware - no internet required",
"features": {
"private": "Private conversations that never leave your device",
"offline": "No internet connection needed after setup",
"questions": "Ask questions, get help with writing, brainstorm ideas",
"local": "Runs on your own hardware with local AI models"
}
},
"notes": {
"name": "Notes",
"description": "Simple note-taking app with local storage",
"features": {
"markdown": "Markdown support",
"local": "All notes stored locally",
"noAccount": "No account required"
}
},
"datatools": {
"name": "Data Tools",
"description": "Swiss Army knife for data encoding, encryption, and analysis",
"features": {
"encode": "Encode/decode data (Base64, hex, etc.)",
"encryption": "Encryption and hashing tools",
"conversion": "Data format conversion"
}
}
},
"installed": "Installed",
"steps": {
"apps": "Apps",
"maps": "Maps",
"content": "Content",
"review": "Review"
},
"step1": {
"heading": "What do you want NOMAD to do?",
"subheading": "Select the capabilities you need. You can always add more later.",
"allInstalled": "All available capabilities are already installed!",
"manageApps": "Manage Apps",
"coreCapabilities": "Core Capabilities",
"additionalTools": "Additional Tools"
},
"step2": {
"heading": "Choose Map Regions",
"subheading": "Select map region collections to download for offline use. You can always download more regions later.",
"noCollections": "No map collections available at this time."
},
"step3": {
"heading": "Choose Content",
"subtextBoth": "Select AI models and content categories for offline use.",
"subtextAi": "Select AI models to download for offline use.",
"subtextInfo": "Select content categories for offline knowledge.",
"subtextDefault": "Configure content for your selected capabilities.",
"aiModels": "AI Models",
"aiModelsSubtext": "Select models to download for offline AI",
"size": "Size: {{size}}",
"noModels": "No recommended AI models available at this time.",
"additionalContent": "Additional Content",
"additionalContentSubtext": "Curated collections for offline reference",
"noContentCapabilities": "No content-based capabilities selected. You can skip this step or go back to select capabilities that require content."
},
"step4": {
"heading": "Review Your Selections",
"subheading": "Review your choices before starting the setup process.",
"noSelections": "No Selections Made",
"noSelectionsMessage": "You haven't selected anything to install or download. You can go back to make selections or go back to the home page.",
"capabilitiesToInstall": "Capabilities to Install",
"mapCollections": "Map Collections to Download ({{count}})",
"contentCategories": "Content Categories ({{count}})",
"files": "{{count}} files",
"wikipedia": "Wikipedia",
"noDownload": "No download",
"aiModelsToDownload": "AI Models to Download ({{count}})",
"readyToStart": "Ready to Start",
"readyToStartMessage": "Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
},
"noInternet": "No Internet Connection",
"noInternetMessage": "You'll need an internet connection to proceed. Please connect to the internet and try again.",
"noInternetSetup": "You must have an internet connection to complete the setup.",
"setupComplete": "Setup wizard completed! Your selections are being processed.",
"setupError": "An error occurred during setup. Some items may not have been processed.",
"back": "Back",
"next": "Next",
"cancelGoHome": "Cancel & Go to Home",
"completeSetup": "Complete Setup",
"selectionSummary": "{{capabilities}} {{capabilityLabel}}, {{maps}} map region{{mapPlural}}, {{categories}} content {{categoryLabel}}, {{models}} AI model{{modelPlural}} selected",
"capability": "capability",
"capabilities": "capabilities"
},
"apps": {
"title": "App Settings",
"heading": "Apps",
"description": "Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.",
"checkForUpdates": "Check for Updates",
"noInternetUpdates": "You must have an internet connection to check for updates.",
"failedDispatchUpdate": "Failed to dispatch update check",
"failedCheckUpdates": "Failed to check for updates: {{error}}",
"installService": "Install Service?",
"install": "Install",
"cancel": "Cancel",
"installConfirm": "Are you sure you want to install {{name}}? This will start the service and make it available in your Project N.O.M.A.D. instance. It may take some time to complete.",
"noInternetInstall": "You must have an internet connection to install services.",
"installInternalError": "An internal error occurred while trying to install the service.",
"failedInstall": "Failed to install service: {{error}}",
"affectInternalError": "An internal error occurred while trying to affect the service.",
"failedAction": "Failed to {{action}} service: {{error}}",
"forceReinstall": "Force Reinstall",
"forceReinstallTitle": "Force Reinstall?",
"forceReinstallConfirm": "Are you sure you want to force reinstall {{name}}? This will <strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should only do this if the service is malfunctioning and other troubleshooting steps have failed.",
"forceReinstallInternalError": "An internal error occurred while trying to force reinstall the service.",
"failedForceReinstall": "Failed to force reinstall service: {{error}}",
"open": "Open",
"update": "Update",
"stopService": "Stop Service?",
"startService": "Start Service?",
"stop": "Stop",
"start": "Start",
"stopConfirm": "Are you sure you want to stop {{name}}?",
"startConfirm": "Are you sure you want to start {{name}}?",
"restartService": "Restart Service?",
"restart": "Restart",
"restartConfirm": "Are you sure you want to restart {{name}}?",
"columns": {
"name": "Name",
"location": "Location",
"installed": "Installed",
"version": "Version",
"actions": "Actions"
}
},
"system": {
"title": "System Information",
"heading": "System Information",
"subtitle": "Real-time monitoring and diagnostics • Last updated: {{time}} • Refreshing data every 30 seconds",
"highMemory": "Very High Memory Usage Detected",
"highMemoryMessage": "System memory usage exceeds 90%. Performance degradation may occur.",
"resourceUsage": "Resource Usage",
"cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage",
"swapUsage": "Swap Usage",
"systemDetails": "System Details",
"operatingSystem": "Operating System",
"distribution": "Distribution",
"kernelVersion": "Kernel Version",
"architecture": "Architecture",
"hostname": "Hostname",
"platform": "Platform",
"processor": "Processor",
"manufacturer": "Manufacturer",
"brand": "Brand",
"cores": "Cores",
"coresCount": "{{count}} cores",
"physicalCores": "Physical Cores",
"virtualization": "Virtualization",
"enabled": "Enabled",
"disabled": "Disabled",
"gpuNotAccessible": "GPU Not Accessible to AI Assistant",
"gpuNotAccessibleMessage": "Your system has an NVIDIA GPU, but the AI Assistant can't access it. AI is running on CPU only, which is significantly slower.",
"fixReinstallAi": "Fix: Reinstall AI Assistant",
"graphics": "Graphics",
"gpuModel": "GPU {{index}} Model",
"gpuVendor": "GPU {{index}} Vendor",
"gpuVram": "GPU {{index}} VRAM",
"na": "N/A",
"memoryAllocation": "Memory Allocation",
"totalRam": "Total RAM",
"usedRam": "Used RAM",
"availableRam": "Available RAM",
"utilized": "{{percent}}% Utilized",
"storageDevices": "Storage Devices",
"normal": "Normal",
"warningUsageHigh": "Warning - Usage High",
"criticalDiskFull": "Critical - Disk Almost Full",
"noStorageDevices": "No storage devices detected",
"systemStatus": "System Status",
"systemUptime": "System Uptime",
"cpuCores": "CPU Cores",
"reinstallAi": "Reinstall AI Assistant?",
"reinstallConfirm": "Reinstall",
"reinstallAiMessage": "This will recreate the AI Assistant container with GPU support enabled. Your downloaded models will be preserved. The service will be briefly unavailable during reinstall.",
"reinstallSuccess": "AI Assistant is being reinstalled with GPU support. This page will reload shortly.",
"reinstallFailed": "Failed to reinstall: {{error}}"
},
"common": {
"noRecordsFound": "No records found",
"activeDownloads": "Active Downloads",
"noActiveDownloads": "No active downloads",
"storage": "Storage",
"selected": "selected",
"current": "Current ({{size}})",
"selectedAddition": "Selected (+{{size}})",
"exceedsSpace": "Exceeds available space by {{size}}",
"willRemainFree": "{{size}} will remain free",
"documentation": "Documentation",
"settings": "Settings",
"cancel": "Cancel",
"confirm": "Confirm",
"backToHome": "Back to Home",
"closeSidebar": "Close sidebar",
"loading": "Loading",
"loadingEllipsis": "Loading...",
"downloadComplete": "Download complete",
"estSpeed": "Est. Speed: {{speed}}",
"installationActivity": "Installation Activity",
"items": "Items: {{count}} | Size: {{size}}",
"allItemsDownloaded": "All items downloaded",
"activeModelDownloads": "Active Model Downloads",
"noActiveModelDownloads": "No active model downloads",
"processingQueue": "Processing Queue",
"noFilesProcessing": "No files are currently being processed",
"delete": "Delete",
"upload": "Upload"
},
"settingsNav": {
"apps": "Apps",
"benchmark": "Benchmark",
"contentExplorer": "Content Explorer",
"contentManager": "Content Manager",
"mapsManager": "Maps Manager",
"serviceLogs": "Service Logs & Metrics",
"checkForUpdates": "Check for Updates",
"system": "System",
"legalNotices": "Legal Notices",
"supportProject": "Support the Project"
},
"about": {
"title": "About",
"hello": "Hello from About!"
},
"docs": {
"title": "Documentation"
},
"easySetupComplete": {
"title": "Easy Setup Wizard Complete",
"noInternet": "No Internet Connection",
"noInternetMessage": "It looks like you're not connected to the internet. Installing apps and downloading content will require an internet connection.",
"installActivity": "App Installation Activity",
"runningInBackground": "Running in the Background",
"runningInBackgroundMessage": "Feel free to leave this page at any time - your app installs and downloads will continue in the background! Please note, the Information Library (if installed) may be unavailable until all initial downloads complete.",
"goToHome": "Go to Home"
},
"errors": {
"notFound": "Page not found",
"notFoundMessage": "This page does not exist.",
"serverError": "Server Error"
},
"mapsManager": {
"title": "Maps Manager",
"heading": "Maps Manager",
"description": "Manage your stored map files and explore new regions!",
"baseAssetsAlert": "The base map assets have not been installed. Please download them first to enable map functionality.",
"downloadBaseAssets": "Download Base Assets",
"baseAssetsSuccess": "Base map assets downloaded successfully.",
"baseAssetsError": "An error occurred while downloading the base map assets. Please try again.",
"baseAssetsUnknownError": "An unknown error occurred while downloading base assets.",
"curatedMapRegions": "Curated Map Regions",
"forceRefreshCollections": "Force Refresh Collections",
"refreshSuccess": "Successfully refreshed map collections.",
"noCuratedCollections": "No curated collections available.",
"storedMapFiles": "Stored Map Files",
"downloadCustomMapFile": "Download a Custom Map File",
"downloadMapFileTitle": "Download Map File",
"downloadModalDescription": "Enter the URL of the map region file you wish to download. The URL must be publicly reachable and end with .pmtiles. A preflight check will be run to verify the file's availability, type, and approximate size.",
"downloadModalPlaceholder": "Enter download URL...",
"preflightRunning": "Running preflight check for URL: {{url}}",
"preflightPassed": "Preflight check passed. Filename: {{filename}}, Size: {{size}} MB",
"preflightUnknownError": "An unknown error occurred during the preflight check.",
"preflightFailed": "Preflight check failed: {{error}}",
"confirmDelete": "Confirm Delete?",
"confirmDeleteMessage": "Are you sure you want to delete {{name}}? This action cannot be undone.",
"delete": "Delete",
"cancel": "Cancel",
"confirmDownload": "Confirm Download?",
"confirmDownloadMessage": "Are you sure you want to download <strong>{{name}}</strong>? It may take some time for it to be available depending on the file size and your internet connection.",
"download": "Download",
"allResourcesDownloaded": "All resources in the collection \"{{name}}\" have already been downloaded.",
"downloadQueued": "Download for collection \"{{name}}\" has been queued.",
"customDownloadQueued": "Download has been queued.",
"columns": {
"name": "Name",
"actions": "Actions"
}
},
"contentManager": {
"title": "Content Manager",
"heading": "Content Manager",
"description": "Manage your stored content files.",
"kiwixNotInstalled": "The Kiwix application is not installed. Please install it to view downloaded ZIM files",
"confirmDelete": "Confirm Delete?",
"confirmDeleteMessage": "Are you sure you want to delete {{name}}? This action cannot be undone.",
"delete": "Delete",
"cancel": "Cancel",
"columns": {
"title": "Title",
"summary": "Summary",
"actions": "Actions"
}
},
"contentExplorer": {
"title": "Content Explorer",
"heading": "Content Explorer",
"description": "Browse and download content for offline reading!",
"noInternet": "No internet connection. You may not be able to download files.",
"kiwixNotInstalled": "The Kiwix application is not installed. Please install it to view downloaded content files.",
"curatedContent": "Curated Content",
"forceRefreshCollections": "Force Refresh Collections",
"refreshSuccess": "Successfully refreshed content collections.",
"additionalContent": "Additional Content",
"additionalContentSubtext": "Curated collections for offline reference",
"noCuratedCategories": "No curated content categories available.",
"browseKiwixLibrary": "Browse the Kiwix Library",
"searchPlaceholder": "Search available ZIM files...",
"confirmDownload": "Confirm Download?",
"confirmDownloadMessage": "Are you sure you want to download <strong>{{title}}</strong>? It may take some time for it to be available depending on the file size and your internet connection. The Kiwix application will be restarted after the download is complete.",
"download": "Download",
"cancel": "Cancel",
"tierDownloadStarted": "Started downloading \"{{name}}\"",
"tierDownloadError": "An error occurred while starting downloads.",
"wikipediaRemoved": "Wikipedia removed successfully",
"wikipediaDownloadStarted": "Wikipedia download started",
"wikipediaSelectionFailed": "Failed to change Wikipedia selection",
"wikipediaSelectionError": "An error occurred while changing Wikipedia selection",
"columns": {
"size": "Size",
"title": "Title",
"author": "Author",
"summary": "Summary",
"updated": "Updated",
"actions": "Actions"
},
"tierModal": {
"selectTierDescription": "Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.",
"includes": "includes {{name}}",
"additionalResources": "{{count}} additional resource",
"additionalResources_plural": "{{count}} additional resources",
"plusEverythingIn": "plus everything in {{name}}",
"resourcesIncluded": "{{count}} resource included",
"resourcesIncluded_plural": "{{count}} resources included",
"changeNote": "You can change your selection at any time. Click Submit to confirm your choice.",
"submit": "Submit"
},
"wikipedia": {
"title": "Wikipedia",
"selectPackage": "Select your preferred Wikipedia package",
"downloading": "Downloading Wikipedia... This may take a while for larger packages.",
"installed": "Installed",
"selected": "Selected",
"downloadingBadge": "Downloading",
"noDownload": "No download",
"removeWikipedia": "Remove Wikipedia",
"downloadSelected": "Download Selected"
},
"categoryCard": {
"tiersAvailable": "{{count}} tiers available",
"clickToChoose": "Click to choose",
"size": "Size: {{min}} - {{max}}"
}
},
"models": {
"title": "{{name}} Settings",
"description": "Easily manage the {{name}}'s settings and installed models. We recommend starting with smaller models first to see how they perform on your system before moving on to larger ones.",
"notInstalled": "{{name}}'s dependencies are not installed. Please install them to manage AI models.",
"gpuNotAccessible": "GPU Not Accessible",
"gpuNotAccessibleMessage": "Your system has an NVIDIA GPU, but {{name}} can't access it. AI is running on CPU only, which is significantly slower.",
"fixReinstall": "Fix: Reinstall {{name}}",
"reinstallTitle": "Reinstall AI Assistant?",
"reinstallMessage": "This will recreate the {{name}} container with GPU support enabled. Your downloaded models will be preserved. The service will be briefly unavailable during reinstall.",
"reinstallSuccess": "{{name}} is being reinstalled with GPU support. This page will reload shortly.",
"reinstallFailed": "Failed to reinstall: {{error}}",
"reinstall": "Reinstall",
"cancel": "Cancel",
"settings": "Settings",
"chatSuggestions": "Chat Suggestions",
"chatSuggestionsDescription": "Display AI-generated conversation starters in the chat interface",
"assistantName": "Assistant Name",
"assistantNameHelp": "Give your AI assistant a custom name that will be used in the chat interface and other areas of the application.",
"assistantNamePlaceholder": "AI Assistant",
"modelsHeading": "Models",
"searchPlaceholder": "Search language models..",
"refreshModels": "Refresh Models",
"refreshSuccess": "Model list refreshed from remote.",
"loadMore": "Load More",
"deleteModelTitle": "Delete Model?",
"deleteModelMessage": "Are you sure you want to delete this model? You will need to download it again if you want to use it in the future.",
"delete": "Delete",
"install": "Install",
"downloadInitiated": "Model download initiated for {{name}}. It may take some time to complete.",
"downloadError": "There was an error installing the model: {{name}}. Please try again.",
"deleteSuccess": "Model deleted: {{name}}.",
"deleteError": "There was an error deleting the model: {{name}}. Please try again.",
"settingUpdated": "Setting updated successfully.",
"settingUpdateError": "There was an error updating the setting. Please try again.",
"columns": {
"name": "Name",
"estimatedPulls": "Estimated Pulls",
"lastUpdated": "Last Updated",
"tag": "Tag",
"inputType": "Input Type",
"contextSize": "Context Size",
"modelSize": "Model Size",
"action": "Action"
}
},
"update": {
"title": "System Update",
"heading": "System Update",
"description": "Keep your Project N.O.M.A.D. instance up to date with the latest features and improvements.",
"updateFailed": "Update Failed",
"containerRestarting": "Container Restarting",
"containerRestartingMessage": "The admin container is restarting. This page will reload automatically when the update is complete.",
"connectionLost": "Connection Temporarily Lost (Expected)",
"connectionLostMessage": "You may see error notifications while the backend restarts during the update. This is completely normal and expected. Connection should be restored momentarily.",
"updateAvailable": "Update Available",
"systemUpToDate": "System Up to Date",
"newVersionAvailable": "A new version ({{version}}) is available for your Project N.O.M.A.D. instance.",
"runningLatest": "Your system is running the latest version!",
"preparingUpdate": "Preparing Update",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"startUpdate": "Start Update",
"noUpdateAvailable": "No Update Available",
"checkAgain": "Check Again",
"percentComplete": "{{percent}}% complete",
"whatHappens": "What happens during an update?",
"step1Title": "Pull Latest Images",
"step1Description": "Downloads the newest Docker images for all core containers",
"step2Title": "Recreate Containers",
"step2Description": "Safely stops and recreates all core containers with the new images",
"step3Title": "Automatic Reload",
"step3Description": "This page will automatically reload when the update is complete",
"viewUpdateLogs": "View Update Logs",
"backupReminder": "Backup Reminder",
"backupReminderMessage": "While updates are designed to be safe, it's always recommended to backup any critical data before proceeding.",
"temporaryDowntime": "Temporary Downtime",
"temporaryDowntimeMessage": "Services will be briefly unavailable during the update process. This typically takes 2-5 minutes depending on your internet connection.",
"earlyAccess": "Early Access",
"enableEarlyAccess": "Enable Early Access",
"enableEarlyAccessDescription": "Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical.",
"contentUpdates": "Content Updates",
"contentUpdatesDescription": "Check if newer versions of your installed ZIM files and maps are available.",
"checkForContentUpdates": "Check for Content Updates",
"updateCheckIssue": "Update Check Issue",
"allContentUpToDate": "All Content Up to Date",
"allContentUpToDateMessage": "All your installed content is running the latest available version.",
"updatesAvailable": "{{count}} update(s) available",
"updateAll": "Update All ({{count}})",
"updateButton": "Update",
"startedUpdates": "Started {{count}} update(s)",
"failedUpdates": "{{count}} update(s) could not be started",
"failedToApplyUpdates": "Failed to apply updates",
"updateStarted": "Update started for {{id}}",
"failedToStartUpdate": "Failed to start update",
"failedToStartUpdateFor": "Failed to start update for {{id}}",
"failedToCheckUpdates": "Failed to check for content updates",
"lastChecked": "Last checked:",
"updateLogs": "Update Logs",
"noLogsAvailable": "No logs available yet...",
"close": "Close",
"settingUpdated": "Setting updated successfully.",
"settingUpdateError": "There was an error updating the setting. Please try again.",
"updateAvailableNotification": "Update available: {{version}}",
"systemUpToDateNotification": "System is up to date",
"failedToCheckForUpdates": "Failed to check for updates",
"subscribeHeading": "Want to stay updated with the latest from Project N.O.M.A.D.? Subscribe to receive release notes directly to your inbox. Unsubscribe anytime.",
"emailPlaceholder": "Your email address",
"subscribe": "Subscribe",
"privacyNote": "We care about your privacy. Project N.O.M.A.D. will never share your email with third parties or send you spam.",
"subscribeSuccess": "Successfully subscribed to release notes!",
"subscribeFailed": "Failed to subscribe: {{error}}",
"subscribeError": "Error subscribing to release notes: {{error}}",
"columns": {
"title": "Title",
"type": "Type",
"version": "Version"
},
"zim": "ZIM",
"map": "Map"
},
"benchmark": {
"title": "System Benchmark",
"heading": "System Benchmark",
"description": "Measure your server's performance and compare with the NOMAD community",
"runBenchmark": "Run Benchmark",
"runningBenchmark": "Running benchmark...",
"benchmarkFailed": "Benchmark Failed",
"aiRequired": "{{name}} Required",
"aiRequiredMessage": "Full benchmark requires {{name}} to be installed. Install it to measure your complete NOMAD capability and share results with the community.",
"goToApps": "Go to Apps to install {{name}} →",
"benchmarkDescription": "Run a benchmark to measure your system's CPU, memory, disk, and AI inference performance. The benchmark takes approximately 2-5 minutes to complete.",
"runFullBenchmark": "Run Full Benchmark",
"systemOnly": "System Only",
"aiOnly": "AI Only",
"aiNotInstalledNote": "{{name}} is not installed.",
"installIt": "Install it",
"aiNotInstalledSuffix": "to run full benchmarks and share results with the community.",
"aiOnlyTooltip": "{{name}} must be installed to run AI benchmark",
"nomadScore": "NOMAD Score",
"outOf100": "out of 100",
"nomadScoreDescription": "Your NOMAD Score is a weighted composite of all benchmark results.",
"shareWithCommunity": "Share with Community",
"shareDescription": "Share your benchmark on the community leaderboard. Choose a Builder Tag to claim your spot, or share anonymously.",
"yourBuilderTag": "Your Builder Tag",
"shareAnonymously": "Share anonymously (no Builder Tag shown on leaderboard)",
"submitting": "Submitting...",
"shareButton": "Share with Community",
"submissionFailed": "Submission Failed",
"alreadySubmitted": "A benchmark for this system with the same or higher score has already been submitted.",
"partialBenchmark": "Partial Benchmark",
"partialBenchmarkMessage": "This {{type}} benchmark cannot be shared with the community. Run a Full Benchmark with {{name}} installed to share your results.",
"sharedWithCommunity": "Shared with Community",
"sharedMessage": "Your benchmark has been submitted to the community leaderboard. Thanks for contributing!",
"viewLeaderboard": "View the leaderboard →",
"systemPerformance": "System Performance",
"cpu": "CPU",
"memory": "Memory",
"diskRead": "Disk Read",
"diskWrite": "Disk Write",
"aiPerformance": "AI Performance",
"aiScore": "AI Score",
"tokensPerSecond": "Tokens per Second",
"tokensPerSecondTooltip": "How fast the AI generates text. Higher is better. 30+ tokens/sec feels responsive, 60+ feels instant.",
"timeToFirstToken": "Time to First Token",
"timeToFirstTokenTooltip": "How quickly the AI starts responding after you send a message. Lower is better. Under 500ms feels instant.",
"noAIData": "No AI Benchmark Data",
"noAIDataMessage": "Run a Full Benchmark or AI Only benchmark to measure AI inference performance.",
"hardwareInformation": "Hardware Information",
"processor": "Processor",
"systemLabel": "System",
"modelLabel": "Model",
"cores": "Cores",
"threads": "Threads",
"ram": "RAM",
"diskType": "Disk Type",
"gpu": "GPU",
"notDetected": "Not detected",
"benchmarkDetails": "Benchmark Details",
"benchmarkId": "Benchmark ID",
"type": "Type",
"date": "Date",
"fullBenchmarkId": "Full Benchmark ID",
"benchmarkType": "Benchmark Type",
"runDate": "Run Date",
"builderTag": "Builder Tag",
"notSet": "Not set",
"aiModelUsed": "AI Model Used",
"submittedToRepository": "Submitted to Repository",
"yes": "Yes",
"no": "No",
"repositoryId": "Repository ID",
"benchmarkHistory": "Benchmark History",
"benchmarksRecorded": "{{count}} benchmark{{plural}} recorded",
"shared": "Shared",
"score": "Score",
"rawScores": "Raw Scores",
"cpuScore": "CPU Score",
"memoryScore": "Memory Score",
"diskReadScore": "Disk Read Score",
"diskWriteScore": "Disk Write Score",
"aiTokensSec": "AI Tokens/sec",
"aiTimeToFirstToken": "AI Time to First Token",
"benchmarkInfo": "Benchmark Info",
"noBenchmarkResults": "No Benchmark Results",
"noBenchmarkResultsMessage": "Run your first benchmark to see your server's performance scores.",
"progress": {
"starting": "Starting benchmark... This takes 2-5 minutes.",
"completed": "Benchmark completed!",
"failed": "Benchmark failed",
"detectingHardware": "Detecting system hardware...",
"runningCpu": "Running CPU benchmark (30s)...",
"runningMemory": "Running memory benchmark...",
"runningDiskRead": "Running disk read benchmark (30s)...",
"runningDiskWrite": "Running disk write benchmark (30s)...",
"downloadingAiModel": "Downloading AI benchmark model (first run only)...",
"runningAi": "Running AI inference benchmark...",
"calculatingScore": "Calculating NOMAD score..."
},
"stages": {
"starting": "Starting",
"detectingHardware": "Detecting Hardware",
"cpuBenchmark": "CPU Benchmark",
"memoryBenchmark": "Memory Benchmark",
"diskReadTest": "Disk Read Test",
"diskWriteTest": "Disk Write Test",
"downloadingAiModel": "Downloading AI Model",
"aiInferenceTest": "AI Inference Test",
"calculatingScore": "Calculating Score",
"complete": "Complete",
"error": "Error"
}
},
"legal": {
"title": "Legal Notices",
"heading": "Legal Notices",
"licenseAgreement": "License Agreement",
"copyright": "Copyright 2024-2026 Crosstalk Solutions, LLC",
"licenseText1": "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at",
"licenseText2": "Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.",
"thirdParty": "Third-Party Software Attribution",
"thirdPartyIntro": "Project N.O.M.A.D. integrates the following open source projects. We are grateful to their developers and communities:",
"kiwixDescription": "Offline Wikipedia and content reader (GPL-3.0 License)",
"kolibriDescription": "Offline learning platform by Learning Equality (MIT License)",
"ollamaDescription": "Local large language model runtime (MIT License)",
"cyberchefDescription": "Data analysis and encoding toolkit by GCHQ (Apache 2.0 License)",
"flatnotesDescription": "Self-hosted note-taking application (MIT License)",
"qdrantDescription": "Vector search engine for AI knowledge base (Apache 2.0 License)",
"privacyStatement": "Privacy Statement",
"privacyIntro": "Project N.O.M.A.D. is designed with privacy as a core principle:",
"zeroTelemetry": "Zero Telemetry:",
"zeroTelemetryText": "N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.",
"localFirst": "Local-First:",
"localFirstText": "All your data, downloaded content, AI conversations, and notes remain on your device.",
"noAccounts": "No Accounts Required:",
"noAccountsText": "N.O.M.A.D. operates without user accounts or authentication by default.",
"networkOptional": "Network Optional:",
"networkOptionalText": "An internet connection is only required to download content or updates. All installed features work fully offline.",
"contentDisclaimer": "Content Disclaimer",
"contentDisclaimerText1": "Project N.O.M.A.D. provides tools to download and access content from third-party sources including Wikipedia, Wikibooks, medical references, educational platforms, and other publicly available resources.",
"contentDisclaimerText2": "Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy, completeness, or reliability of any third-party content. The inclusion of any content does not constitute an endorsement.",
"contentDisclaimerText3": "Users are responsible for evaluating the appropriateness and accuracy of any content they download and use.",
"medicalDisclaimer": "Medical and Emergency Information Disclaimer",
"medicalDisclaimerText1": "Some content available through N.O.M.A.D. includes medical references, first aid guides, and emergency preparedness information. This content is provided for general informational purposes only.",
"medicalDisclaimerText2": "This information is NOT a substitute for professional medical advice, diagnosis, or treatment.",
"medicalPoint1": "Always seek the advice of qualified health providers with questions about medical conditions.",
"medicalPoint2": "Never disregard professional medical advice or delay seeking it because of something you read in offline content.",
"medicalPoint3": "In a medical emergency, call emergency services immediately if available.",
"medicalPoint4": "Medical information may become outdated. Verify critical information with current professional sources when possible.",
"dataStorage": "Data Storage",
"dataStorageIntro": "All data associated with Project N.O.M.A.D. is stored locally on your device:",
"installationDirectory": "Installation Directory:",
"downloadedContent": "Downloaded Content:",
"applicationData": "Application Data:",
"applicationDataValue": "Stored in Docker volumes on your local system",
"dataStorageNote": "You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these directories will permanently remove all associated data."
},
"updateService": {
"title": "Update Service",
"update": "Update",
"failedToLoadVersions": "Failed to load available versions",
"updateConfirmation": "Update <strong>{{name}}</strong> from <code>{{from}}</code> to <code>{{to}}</code>?",
"dataPreserved": "Your data and configuration will be preserved during the update.",
"viewReleaseNotes": "View release notes",
"showVersions": "Show available versions",
"hideVersions": "Hide available versions",
"loadingVersions": "Loading versions...",
"noVersionsAvailable": "No other versions available",
"latest": "Latest",
"releaseNotes": "Release notes",
"majorVersionWarning": "It's not recommended to upgrade to a new major version (e.g. 1.8.2 → 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible."
},
"chat": {
"newChat": "New Chat",
"noPreviousChats": "No previous chats",
"openInNewTab": "Open in New Tab",
"modelsAndSettings": "Models & Settings",
"knowledgeBase": "Knowledge Base",
"clearHistory": "Clear History",
"startConversation": "Start a conversation",
"interactWithModels": "Interact with your installed language models directly in the Command Center.",
"suggestions": "Suggestions:",
"thinking": "Thinking",
"enableSuggestions": "Need some inspiration? Enable chat suggestions in settings to get started with example prompts.",
"reasoning": "Reasoning",
"thoughtFor": "Thought for {{seconds}}s",
"errorNoResponse": "Sorry, I could not generate a response.",
"errorProcessing": "Sorry, there was an error processing your request. Please try again.",
"noModelsInstalled": "No models installed",
"loadingModels": "Loading models...",
"model": "Model:",
"clearAllHistory": "Clear All Chat History?",
"clearAllConfirm": "Are you sure you want to delete all chat sessions? This action cannot be undone and all conversations will be permanently deleted.",
"clearAll": "Clear All",
"typePlaceholder": "Type your message to {{name}}... (Shift+Enter for new line)",
"ragModelNotInstalled": "The {{model}} model is not installed. Consider <button>downloading it</button> for improved retrieval-augmented generation (RAG) performance.",
"downloadModelTitle": "Download {{model}}?",
"download": "Download",
"downloadModelDescription": "This will dispatch a background download job for <strong>{{model}}</strong> and may take some time to complete. The model will be used to rewrite queries for improved RAG retrieval performance.",
"modelDownloadQueued": "Model download queued",
"modelDownloadFailed": "Failed to queue model download",
"knowledgeBaseTitle": "Knowledge Base",
"uploadSuccess": "Document uploaded and queued for processing",
"uploadFailed": "Failed to upload document",
"fileRemoved": "File removed from knowledge base.",
"fileDeleteFailed": "Failed to delete file.",
"confirmSync": "Confirm Sync?",
"confirmSyncButton": "Confirm Sync",
"syncDescription": "This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date. This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?",
"syncSuccess": "Storage synced successfully. If new files were found, they have been queued for processing.",
"syncFailed": "Failed to sync storage",
"whyUpload": "Why upload documents to your Knowledge Base?",
"kbIntegrationTitle": "{{name}} Knowledge Base Integration",
"kbIntegrationDescription": "When you upload documents to your Knowledge Base, NOMAD processes and embeds the content, making it directly accessible to {{name}}. This allows {{name}} to reference your specific documents during conversations, providing more accurate and personalized responses based on your uploaded data.",
"ocrTitle": "Enhanced Document Processing with OCR",
"ocrDescription": "NOMAD includes built-in Optical Character Recognition (OCR) capabilities, allowing it to extract text from image-based documents such as scanned PDFs or photos. This means that even if your documents are not in a standard text format, NOMAD can still process and embed their content for AI access.",
"libraryTitle": "Information Library Integration",
"libraryDescription": "NOMAD will automatically discover and extract any content you save to your Information Library (if installed), making it instantly available to {{name}} without any extra steps.",
"storedFiles": "Stored Knowledge Base Files",
"syncStorage": "Sync Storage",
"fileName": "File Name",
"removeFromKb": "Remove from knowledge base?",
"deleting": "Deleting…"
},
"support": {
"title": "Support the Project",
"subtitle": "Project NOMAD is 100% free and open source — no subscriptions, no paywalls, no catch. If you'd like to help keep the project going, here are a few ways to show your support.",
"kofiTitle": "Buy Us a Coffee",
"kofiDescription": "Every contribution helps fund development, server costs, and new content packs for NOMAD. Even a small donation goes a long way.",
"kofiButton": "Support on Ko-fi",
"rogueTitle": "Need Help With Your Home Network?",
"rogueBannerAlt": "Rogue Support — Conquer Your Home Network",
"rogueDescription": "Rogue Support is a networking consultation service for home users. Think of it as Uber for computer networking — expert help when you need it.",
"rogueButton": "Visit RogueSupport.com",
"otherTitle": "Other Ways to Help",
"starOnGithub": "Star the project on GitHub",
"starOnGithubSuffix": "it helps more people discover NOMAD",
"reportBugs": "Report bugs and suggest features",
"reportBugsSuffix": "every report makes NOMAD better",
"shareNomad": "Share NOMAD with someone who'd use it — word of mouth is the best marketing",
"joinDiscord": "Join the Discord community",
"joinDiscordSuffix": "hang out, share your build, help other users"
},
"debugInfo": {
"title": "Debug Info",
"close": "Close",
"description": "This is non-sensitive system info you can share when reporting issues. No passwords, IPs, or API keys are included.",
"loadFailed": "Failed to load debug info. Please try again.",
"copied": "Copied!",
"copyToClipboard": "Copy to Clipboard",
"openGithubIssue": "Open a GitHub Issue"
},
"footer": {
"commandCenter": "Project N.O.M.A.D. Command Center v{{version}}",
"debugInfo": "Debug Info"
}
}

View File

@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next'
import AppLayout from '~/layouts/AppLayout'
export default function About() {
const { t } = useTranslation()
return (
<AppLayout>
<div className="p-2">Hello from About!</div>
<div className="p-2">{t('about.hello')}</div>
</AppLayout>
)
}

View File

@ -1,11 +1,14 @@
import { Head } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import MarkdocRenderer from '~/components/MarkdocRenderer'
import DocsLayout from '~/layouts/DocsLayout'
export default function Show({ content }: { content: any; }) {
const { t } = useTranslation()
return (
<DocsLayout>
<Head title={'Documentation'} />
<Head title={t('docs.title')} />
<div className="xl:pl-80 pt-14 xl:pt-8 pb-8 px-6 sm:px-8 lg:px-12">
<div className="max-w-4xl">
<MarkdocRenderer content={content} />

View File

@ -1,4 +1,5 @@
import { Head, router } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import Alert from '~/components/Alert'
@ -9,16 +10,17 @@ import ActiveDownloads from '~/components/ActiveDownloads'
import StyledSectionHeader from '~/components/StyledSectionHeader'
export default function EasySetupWizardComplete() {
const { t } = useTranslation()
const { isOnline } = useInternetStatus()
const installActivity = useServiceInstallationActivity()
return (
<AppLayout>
<Head title="Easy Setup Wizard Complete" />
<Head title={t('easySetupComplete.title')} />
{!isOnline && (
<Alert
title="No Internet Connection"
message="It looks like you're not connected to the internet. Installing apps and downloading content will require an internet connection."
title={t('easySetupComplete.noInternet')}
message={t('easySetupComplete.noInternetMessage')}
type="warning"
variant="solid"
className="mb-8"
@ -26,15 +28,15 @@ export default function EasySetupWizardComplete() {
)}
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-surface-primary rounded-md shadow-md p-6">
<StyledSectionHeader title="App Installation Activity" className=" mb-4" />
<StyledSectionHeader title={t('easySetupComplete.installActivity')} className=" mb-4" />
<InstallActivityFeed
activity={installActivity}
className="!shadow-none border-desert-stone-light border"
/>
<ActiveDownloads withHeader />
<Alert
title="Running in the Background"
message='Feel free to leave this page at any time - your app installs and downloads will continue in the background! Please note, the Information Library (if installed) may be unavailable until all initial downloads complete.'
title={t('easySetupComplete.runningInBackground')}
message={t('easySetupComplete.runningInBackgroundMessage')}
type="info"
variant="solid"
className='mt-12'
@ -42,7 +44,7 @@ export default function EasySetupWizardComplete() {
<div className="flex justify-center mt-8 pt-4 border-t border-desert-stone-light">
<div className="flex space-x-4">
<StyledButton onClick={() => router.visit('/home')} icon="IconHome">
Go to Home
{t('easySetupComplete.goToHome')}
</StyledButton>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { Head, router, usePage } from '@inertiajs/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppLayout from '~/layouts/AppLayout'
import StyledButton from '~/components/StyledButton'
import api from '~/lib/api'
@ -33,33 +34,33 @@ interface Capability {
icon: string
}
function buildCoreCapabilities(aiAssistantName: string): Capability[] {
function buildCoreCapabilities(aiAssistantName: string, t: (key: string) => string): Capability[] {
return [
{
id: 'information',
name: 'Information Library',
name: t('easySetup.capabilities.information.name'),
technicalName: 'Kiwix',
description:
'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
t('easySetup.capabilities.information.description'),
features: [
'Complete Wikipedia offline',
'Medical references and first aid guides',
'DIY repair guides and how-to content',
'Project Gutenberg books and literature',
t('easySetup.capabilities.information.features.wikipedia'),
t('easySetup.capabilities.information.features.medical'),
t('easySetup.capabilities.information.features.diy'),
t('easySetup.capabilities.information.features.gutenberg'),
],
services: [SERVICE_NAMES.KIWIX],
icon: 'IconBooks',
},
{
id: 'education',
name: 'Education Platform',
name: t('easySetup.capabilities.education.name'),
technicalName: 'Kolibri',
description: 'Interactive learning platform with video courses and exercises',
description: t('easySetup.capabilities.education.description'),
features: [
'Khan Academy math and science courses',
'K-12 curriculum content',
'Interactive exercises and quizzes',
'Progress tracking for learners',
t('easySetup.capabilities.education.features.khan'),
t('easySetup.capabilities.education.features.k12'),
t('easySetup.capabilities.education.features.exercises'),
t('easySetup.capabilities.education.features.progress'),
],
services: [SERVICE_NAMES.KOLIBRI],
icon: 'IconSchool',
@ -68,12 +69,12 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
id: 'ai',
name: aiAssistantName,
technicalName: 'Ollama',
description: 'Local AI chat that runs entirely on your hardware - no internet required',
description: t('easySetup.capabilities.ai.description'),
features: [
'Private conversations that never leave your device',
'No internet connection needed after setup',
'Ask questions, get help with writing, brainstorm ideas',
'Runs on your own hardware with local AI models',
t('easySetup.capabilities.ai.features.private'),
t('easySetup.capabilities.ai.features.offline'),
t('easySetup.capabilities.ai.features.questions'),
t('easySetup.capabilities.ai.features.local'),
],
services: [SERVICE_NAMES.OLLAMA],
icon: 'IconRobot',
@ -81,30 +82,32 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
]
}
const ADDITIONAL_TOOLS: Capability[] = [
{
id: 'notes',
name: 'Notes',
technicalName: 'FlatNotes',
description: 'Simple note-taking app with local storage',
features: ['Markdown support', 'All notes stored locally', 'No account required'],
services: [SERVICE_NAMES.FLATNOTES],
icon: 'IconNotes',
},
{
id: 'datatools',
name: 'Data Tools',
technicalName: 'CyberChef',
description: 'Swiss Army knife for data encoding, encryption, and analysis',
features: [
'Encode/decode data (Base64, hex, etc.)',
'Encryption and hashing tools',
'Data format conversion',
],
services: [SERVICE_NAMES.CYBERCHEF],
icon: 'IconChefHat',
},
]
function buildAdditionalTools(t: (key: string) => string): Capability[] {
return [
{
id: 'notes',
name: t('easySetup.capabilities.notes.name'),
technicalName: 'FlatNotes',
description: t('easySetup.capabilities.notes.description'),
features: [t('easySetup.capabilities.notes.features.markdown'), t('easySetup.capabilities.notes.features.local'), t('easySetup.capabilities.notes.features.noAccount')],
services: [SERVICE_NAMES.FLATNOTES],
icon: 'IconNotes',
},
{
id: 'datatools',
name: t('easySetup.capabilities.datatools.name'),
technicalName: 'CyberChef',
description: t('easySetup.capabilities.datatools.description'),
features: [
t('easySetup.capabilities.datatools.features.encode'),
t('easySetup.capabilities.datatools.features.encryption'),
t('easySetup.capabilities.datatools.features.conversion'),
],
services: [SERVICE_NAMES.CYBERCHEF],
icon: 'IconChefHat',
},
]
}
type WizardStep = 1 | 2 | 3 | 4
@ -113,8 +116,10 @@ const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
export default function EasySetupWizard(props: { system: { services: ServiceSlim[] } }) {
const { t } = useTranslation()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName)
const CORE_CAPABILITIES = buildCoreCapabilities(aiAssistantName, t)
const ADDITIONAL_TOOLS = buildAdditionalTools(t)
const [currentStep, setCurrentStep] = useState<WizardStep>(1)
const [selectedServices, setSelectedServices] = useState<string[]>([])
@ -323,7 +328,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
if (!isOnline) {
addNotification({
type: 'error',
message: 'You must have an internet connection to complete the setup.',
message: t('easySetup.noInternetSetup'),
})
return
}
@ -357,7 +362,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
addNotification({
type: 'success',
message: 'Setup wizard completed! Your selections are being processed.',
message: t('easySetup.setupComplete'),
})
router.visit('/easy-setup/complete')
@ -365,7 +370,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
console.error('Error during setup:', error)
addNotification({
type: 'error',
message: 'An error occurred during setup. Some items may not have been processed.',
message: t('easySetup.setupError'),
})
} finally {
setIsProcessing(false)
@ -408,10 +413,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const renderStepIndicator = () => {
const steps = [
{ number: 1, label: 'Apps' },
{ number: 2, label: 'Maps' },
{ number: 3, label: 'Content' },
{ number: 4, label: 'Review' },
{ number: 1, label: t('easySetup.steps.apps') },
{ number: 2, label: t('easySetup.steps.maps') },
{ number: 3, label: t('easySetup.steps.content') },
{ number: 4, label: t('easySetup.steps.review') },
]
return (
@ -555,7 +560,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</h3>
{installed && (
<span className="text-xs bg-desert-green text-white px-2 py-0.5 rounded-full">
Installed
{t('easySetup.installed')}
</span>
)}
</div>
@ -565,7 +570,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
installed ? 'text-text-muted' : selected ? 'text-green-100' : 'text-text-muted'
)}
>
Powered by {capability.technicalName}
{t('home.poweredBy', { name: capability.technicalName })}
</p>
<p
className={classNames(
@ -635,23 +640,23 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-8">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">What do you want NOMAD to do?</h2>
<h2 className="text-3xl font-bold text-text-primary mb-2">{t('easySetup.step1.heading')}</h2>
<p className="text-text-secondary">
Select the capabilities you need. You can always add more later.
{t('easySetup.step1.subheading')}
</p>
</div>
{allInstalled ? (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">
All available capabilities are already installed!
{t('easySetup.step1.allInstalled')}
</p>
<StyledButton
variant="primary"
className="mt-4"
onClick={() => router.visit('/settings/apps')}
>
Manage Apps
{t('easySetup.step1.manageApps')}
</StyledButton>
</div>
) : (
@ -659,7 +664,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{/* Core Capabilities */}
{existingCoreCapabilities.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-text-primary mb-4">Core Capabilities</h3>
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('easySetup.step1.coreCapabilities')}</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{existingCoreCapabilities.map((capability) =>
renderCapabilityCard(capability, true)
@ -675,7 +680,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-md font-medium text-text-muted">Additional Tools</h3>
<h3 className="text-md font-medium text-text-muted">{t('easySetup.step1.additionalTools')}</h3>
{showAdditionalTools ? (
<IconChevronUp size={20} className="text-text-muted" />
) : (
@ -700,10 +705,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const renderStep2 = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Map Regions</h2>
<h2 className="text-3xl font-bold text-text-primary mb-2">{t('easySetup.step2.heading')}</h2>
<p className="text-text-secondary">
Select map region collections to download for offline use. You can always download more
regions later.
{t('easySetup.step2.subheading')}
</p>
</div>
{isLoadingMaps ? (
@ -737,7 +741,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</div>
) : (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">No map collections available at this time.</p>
<p className="text-text-secondary text-lg">{t('easySetup.step2.noCollections')}</p>
</div>
)}
</div>
@ -753,15 +757,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Content</h2>
<h2 className="text-3xl font-bold text-text-primary mb-2">{t('easySetup.step3.heading')}</h2>
<p className="text-text-secondary">
{isAiSelected && isInformationSelected
? 'Select AI models and content categories for offline use.'
? t('easySetup.step3.subtextBoth')
: isAiSelected
? 'Select AI models to download for offline use.'
? t('easySetup.step3.subtextAi')
: isInformationSelected
? 'Select content categories for offline knowledge.'
: 'Configure content for your selected capabilities.'}
? t('easySetup.step3.subtextInfo')
: t('easySetup.step3.subtextDefault')}
</p>
</div>
@ -773,8 +777,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<IconCpu className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">AI Models</h3>
<p className="text-sm text-text-muted">Select models to download for offline AI</p>
<h3 className="text-xl font-semibold text-text-primary">{t('easySetup.step3.aiModels')}</h3>
<p className="text-sm text-text-muted">{t('easySetup.step3.aiModelsSubtext')}</p>
</div>
</div>
@ -823,7 +827,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
: 'text-text-muted'
)}
>
Size: {model.tags[0].size}
{t('easySetup.step3.size', { size: model.tags[0].size })}
</div>
)}
</div>
@ -845,7 +849,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg">
<p className="text-text-secondary">No recommended AI models available at this time.</p>
<p className="text-text-secondary">{t('easySetup.step3.noModels')}</p>
</div>
)}
</div>
@ -886,8 +890,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<IconBooks className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
<h3 className="text-xl font-semibold text-text-primary">{t('easySetup.step3.additionalContent')}</h3>
<p className="text-sm text-text-muted">{t('easySetup.step3.additionalContentSubtext')}</p>
</div>
</div>
@ -930,8 +934,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{!isAiSelected && !isInformationSelected && (
<div className="text-center py-12">
<p className="text-text-secondary text-lg">
No content-based capabilities selected. You can skip this step or go back to select
capabilities that require content.
{t('easySetup.step3.noContentCapabilities')}
</p>
</div>
)}
@ -950,14 +953,14 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-text-primary mb-2">Review Your Selections</h2>
<p className="text-text-secondary">Review your choices before starting the setup process.</p>
<h2 className="text-3xl font-bold text-text-primary mb-2">{t('easySetup.step4.heading')}</h2>
<p className="text-text-secondary">{t('easySetup.step4.subheading')}</p>
</div>
{!hasSelections ? (
<Alert
title="No Selections Made"
message="You haven't selected anything to install or download. You can go back to make selections or go back to the home page."
title={t('easySetup.step4.noSelections')}
message={t('easySetup.step4.noSelectionsMessage')}
type="info"
variant="bordered"
/>
@ -966,7 +969,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{selectedServices.length > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Capabilities to Install
{t('easySetup.step4.capabilitiesToInstall')}
</h3>
<ul className="space-y-2">
{[...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS]
@ -989,7 +992,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{selectedMapCollections.length > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Map Collections to Download ({selectedMapCollections.length})
{t('easySetup.step4.mapCollections', { count: selectedMapCollections.length })}
</h3>
<ul className="space-y-2">
{selectedMapCollections.map((slug) => {
@ -1008,7 +1011,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{selectedTiers.size > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Content Categories ({selectedTiers.size})
{t('easySetup.step4.contentCategories', { count: selectedTiers.size })}
</h3>
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
const category = categories?.find((c) => c.slug === categorySlug)
@ -1022,7 +1025,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{category.name} - {tier.name}
</span>
<span className="text-text-muted text-sm ml-2">
({resources.length} files)
({t('easySetup.step4.files', { count: resources.length })})
</span>
</div>
<ul className="ml-7 space-y-1">
@ -1040,7 +1043,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{selectedWikipedia && selectedWikipedia !== 'none' && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">Wikipedia</h3>
<h3 className="text-xl font-semibold text-text-primary mb-4">{t('easySetup.step4.wikipedia')}</h3>
{(() => {
const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)
return option ? (
@ -1052,7 +1055,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<span className="text-text-muted text-sm">
{option.size_mb > 0
? `${(option.size_mb / 1024).toFixed(1)} GB`
: 'No download'}
: t('easySetup.step4.noDownload')}
</span>
</div>
) : null
@ -1063,7 +1066,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{selectedAiModels.length > 0 && (
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
AI Models to Download ({selectedAiModels.length})
{t('easySetup.step4.aiModelsToDownload', { count: selectedAiModels.length })}
</h3>
<ul className="space-y-2">
{selectedAiModels.map((modelName) => {
@ -1085,8 +1088,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)}
<Alert
title="Ready to Start"
message="Click 'Complete Setup' to begin installing apps and downloading content. This may take some time depending on your internet connection and the size of the downloads."
title={t('easySetup.step4.readyToStart')}
message={t('easySetup.step4.readyToStartMessage')}
type="info"
variant="solid"
/>
@ -1098,11 +1101,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<AppLayout>
<Head title="Easy Setup Wizard" />
<Head title={t('easySetup.title')} />
{!isOnline && (
<Alert
title="No Internet Connection"
message="You'll need an internet connection to proceed. Please connect to the internet and try again."
title={t('easySetup.noInternet')}
message={t('easySetup.noInternetMessage')}
type="warning"
variant="solid"
className="mb-8"
@ -1135,7 +1138,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
variant="outline"
icon="IconChevronLeft"
>
Back
{t('easySetup.back')}
</StyledButton>
)}
@ -1144,12 +1147,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
cap.services.some((s) => selectedServices.includes(s))
).length
return `${count} ${count === 1 ? 'capability' : 'capabilities'}`
return `${count} ${count === 1 ? t('easySetup.capability') : t('easySetup.capabilities')}`
})()}
, {selectedMapCollections.length} map region
{selectedMapCollections.length !== 1 && 's'}, {selectedTiers.size}{' '}
content categor{selectedTiers.size !== 1 ? 'ies' : 'y'},{' '}
{selectedAiModels.length} AI model{selectedAiModels.length !== 1 && 's'} selected
, {selectedMapCollections.length} {t('easySetup.steps.maps').toLowerCase()}
, {selectedTiers.size} {t('easySetup.steps.content').toLowerCase()}
, {selectedAiModels.length} {t('easySetup.step3.aiModels')}
</p>
</div>
@ -1159,7 +1161,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
disabled={isProcessing}
variant="outline"
>
Cancel & Go to Home
{t('easySetup.cancelGoHome')}
</StyledButton>
{currentStep < 4 ? (
@ -1169,7 +1171,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
variant="primary"
icon="IconChevronRight"
>
Next
{t('easySetup.next')}
</StyledButton>
) : (
<StyledButton
@ -1179,7 +1181,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
variant="success"
icon="IconCheck"
>
Complete Setup
{t('easySetup.completeSetup')}
</StyledButton>
)}
</div>

View File

@ -1,10 +1,14 @@
import { useTranslation } from 'react-i18next'
export default function NotFound() {
const { t } = useTranslation()
return (
<>
<div className="container">
<div className="title">Page not found</div>
<div className="title">{t('errors.notFound')}</div>
<span>This page does not exist.</span>
<span>{t('errors.notFoundMessage')}</span>
</div>
</>
)

View File

@ -1,8 +1,12 @@
import { useTranslation } from 'react-i18next'
export default function ServerError(props: { error: any }) {
const { t } = useTranslation()
return (
<>
<div className="container">
<div className="title">Server Error</div>
<div className="title">{t('errors.serverError')}</div>
<span>{props.error.message}</span>
</div>

View File

@ -7,6 +7,7 @@ import {
IconWifiOff,
} from '@tabler/icons-react'
import { Head, usePage } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
@ -16,63 +17,6 @@ import { useSystemSetting } from '~/hooks/useSystemSetting'
import Alert from '~/components/Alert'
import { SERVICE_NAMES } from '../../constants/service_names'
// Maps is a Core Capability (display_order: 4)
const MAPS_ITEM = {
label: 'Maps',
to: '/maps',
target: '',
description: 'View offline maps',
icon: <IconMapRoute size={48} />,
installed: true,
displayOrder: 4,
poweredBy: null,
}
// System items shown after all apps
const SYSTEM_ITEMS = [
{
label: 'Easy Setup',
to: '/easy-setup',
target: '',
description:
'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!',
icon: <IconBolt size={48} />,
installed: true,
displayOrder: 50,
poweredBy: null,
},
{
label: 'Install Apps',
to: '/settings/apps',
target: '',
description: 'Not seeing your favorite app? Install it here!',
icon: <IconPlus size={48} />,
installed: true,
displayOrder: 51,
poweredBy: null,
},
{
label: 'Docs',
to: '/docs/home',
target: '',
description: 'Read Project N.O.M.A.D. manuals and guides',
icon: <IconHelp size={48} />,
installed: true,
displayOrder: 52,
poweredBy: null,
},
{
label: 'Settings',
to: '/settings/system',
target: '',
description: 'Configure your N.O.M.A.D. settings',
icon: <IconSettings size={48} />,
installed: true,
displayOrder: 53,
poweredBy: null,
},
]
interface DashboardItem {
label: string
to: string
@ -89,6 +33,7 @@ export default function Home(props: {
services: ServiceSlim[]
}
}) {
const { t } = useTranslation()
const items: DashboardItem[] = []
const updateInfo = useUpdateAvailable();
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
@ -99,6 +44,62 @@ export default function Home(props: {
})
const shouldHighlightEasySetup = easySetupVisited?.value ? String(easySetupVisited.value) !== 'true' : false
// Maps is a Core Capability (display_order: 4)
const MAPS_ITEM: DashboardItem = {
label: t('home.maps.label'),
to: '/maps',
target: '',
description: t('home.maps.description'),
icon: <IconMapRoute size={48} />,
installed: true,
displayOrder: 4,
poweredBy: null,
}
// System items shown after all apps
const SYSTEM_ITEMS: DashboardItem[] = [
{
label: t('home.easySetup.label'),
to: '/easy-setup',
target: '',
description: t('home.easySetup.description'),
icon: <IconBolt size={48} />,
installed: true,
displayOrder: 50,
poweredBy: null,
},
{
label: t('home.installApps.label'),
to: '/settings/apps',
target: '',
description: t('home.installApps.description'),
icon: <IconPlus size={48} />,
installed: true,
displayOrder: 51,
poweredBy: null,
},
{
label: t('home.docs.label'),
to: '/docs/home',
target: '',
description: t('home.docs.description'),
icon: <IconHelp size={48} />,
installed: true,
displayOrder: 52,
poweredBy: null,
},
{
label: t('home.settings.label'),
to: '/settings/system',
target: '',
description: t('home.settings.description'),
icon: <IconSettings size={48} />,
installed: true,
displayOrder: 53,
poweredBy: null,
},
]
// Add installed services (non-dependency services only)
props.system.services
.filter((service) => service.installed && service.ui_location)
@ -110,7 +111,7 @@ export default function Home(props: {
target: '_blank',
description:
service.description ||
`Access the ${service.friendly_name || service.service_name} application`,
t('home.accessApp', { name: service.friendly_name || service.service_name }),
icon: service.icon ? (
<DynamicIcon icon={service.icon as DynamicIconName} className="!size-12" />
) : (
@ -133,18 +134,18 @@ export default function Home(props: {
return (
<AppLayout>
<Head title="Command Center" />
<Head title={t('home.title')} />
{
updateInfo?.updateAvailable && (
<div className='flex justify-center items-center p-4 w-full'>
<Alert
title="An update is available for Project N.O.M.A.D.!"
title={t('home.updateAvailable')}
type="info-inverted"
variant="solid"
className="w-full"
buttonProps={{
variant: 'primary',
children: 'Go to Settings',
children: t('home.goToSettings'),
icon: 'IconSettings',
onClick: () => {
window.location.href = '/settings/update'
@ -156,7 +157,7 @@ export default function Home(props: {
}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{items.map((item) => {
const isEasySetup = item.label === 'Easy Setup'
const isEasySetup = item.label === t('home.easySetup.label')
const shouldHighlight = isEasySetup && shouldHighlightEasySetup
return (
@ -169,13 +170,13 @@ export default function Home(props: {
style={{ animationDuration: '1.5s' }}
></span>
<span className="relative inline-flex items-center rounded-full px-2.5 py-1 bg-desert-orange-light text-xs font-semibold text-white shadow-sm">
Start here!
{t('home.startHere')}
</span>
</span>
)}
<div className="flex items-center justify-center mb-2">{item.icon}</div>
<h3 className="font-bold text-2xl">{item.label}</h3>
{item.poweredBy && <p className="text-sm opacity-80">Powered by {item.poweredBy}</p>}
{item.poweredBy && <p className="text-sm opacity-80">{t('home.poweredBy', { name: item.poweredBy })}</p>}
<p className="xl:text-lg mt-2">{item.description}</p>
</div>
</a>

View File

@ -1,5 +1,6 @@
import MapsLayout from '~/layouts/MapsLayout'
import { Head, Link } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import MapComponent from '~/components/maps/MapComponent'
import StyledButton from '~/components/StyledButton'
import { IconArrowLeft } from '@tabler/icons-react'
@ -9,25 +10,26 @@ import Alert from '~/components/Alert'
export default function Maps(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
}) {
const { t } = useTranslation()
const alertMessage = !props.maps.baseAssetsExist
? 'The base map assets have not been installed. Please download them first to enable map functionality.'
? t('maps.alertNoBaseAssets')
: props.maps.regionFiles.length === 0
? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.'
? t('maps.alertNoRegions')
: null
return (
<MapsLayout>
<Head title="Maps" />
<Head title={t('maps.title')} />
<div className="relative w-full h-screen overflow-hidden">
{/* Nav and alerts are overlayed */}
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm">
<Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-text-secondary">Back to Home</p>
<p className="text-lg text-text-secondary">{t('layout.backToHome')}</p>
</Link>
<Link href="/settings/maps" className='mr-4'>
<StyledButton variant="primary" icon="IconSettings">
Manage Map Regions
{t('maps.manageRegions')}
</StyledButton>
</Link>
</div>
@ -40,7 +42,7 @@ export default function Maps(props: {
className="w-full"
buttonProps={{
variant: 'secondary',
children: 'Go to Map Settings',
children: t('maps.goToSettings'),
icon: 'IconSettings',
onClick: () => {
window.location.href = '/settings/maps'

View File

@ -13,6 +13,7 @@ import LoadingSpinner from '~/components/LoadingSpinner'
import useErrorNotification from '~/hooks/useErrorNotification'
import useInternetStatus from '~/hooks/useInternetStatus'
import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivity'
import { useTranslation } from 'react-i18next'
import { useTransmit } from 'react-adonis-transmit'
import { BROADCAST_CHANNELS } from '../../../constants/broadcast'
import { IconArrowUp, IconCheck, IconDownload } from '@tabler/icons-react'
@ -25,6 +26,7 @@ function extractTag(containerImage: string): string {
}
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
const { t } = useTranslation()
const { openModal, closeAllModals } = useModals()
const { showError } = useErrorNotification()
const { isOnline } = useInternetStatus()
@ -60,17 +62,17 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
async function handleCheckUpdates() {
try {
if (!isOnline) {
showError('You must have an internet connection to check for updates.')
showError(t('apps.noInternetUpdates'))
return
}
setCheckingUpdates(true)
const response = await api.checkServiceUpdates()
if (!response?.success) {
throw new Error('Failed to dispatch update check')
throw new Error(t('apps.failedDispatchUpdate'))
}
} catch (error) {
console.error('Error checking for updates:', error)
showError(`Failed to check for updates: ${error.message || 'Unknown error'}`)
showError(t('apps.failedCheckUpdates', { error: error.message || 'Unknown error' }))
setCheckingUpdates(false)
}
}
@ -78,22 +80,20 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
const handleInstallService = (service: ServiceSlim) => {
openModal(
<StyledModal
title="Install Service?"
title={t('apps.installService')}
onConfirm={() => {
installService(service.service_name)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Install"
cancelText="Cancel"
confirmText={t('apps.install')}
cancelText={t('apps.cancel')}
confirmVariant="primary"
icon={<IconDownload className="h-12 w-12 text-desert-green" />}
>
<p className="text-text-primary">
Are you sure you want to install {service.friendly_name || service.service_name}? This
will start the service and make it available in your Project N.O.M.A.D. instance. It may
take some time to complete.
{t('apps.installConfirm', { name: service.friendly_name || service.service_name })}
</p>
</StyledModal>,
'install-service-modal'
@ -103,21 +103,21 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
async function installService(serviceName: string) {
try {
if (!isOnline) {
showError('You must have an internet connection to install services.')
showError(t('apps.noInternetInstall'))
return
}
setIsInstalling(true)
const response = await api.installService(serviceName)
if (!response) {
throw new Error('An internal error occurred while trying to install the service.')
throw new Error(t('apps.installInternalError'))
}
if (!response.success) {
throw new Error(response.message)
}
} catch (error) {
console.error('Error installing service:', error)
showError(`Failed to install service: ${error.message || 'Unknown error'}`)
showError(t('apps.failedInstall', { error: error.message || 'Unknown error' }))
} finally {
setIsInstalling(false)
}
@ -128,7 +128,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setLoading(true)
const response = await api.affectService(record.service_name, action)
if (!response) {
throw new Error('An internal error occurred while trying to affect the service.')
throw new Error(t('apps.affectInternalError'))
}
if (!response.success) {
throw new Error(response.message)
@ -142,7 +142,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
}, 3000)
} catch (error) {
console.error(`Error affecting service ${record.service_name}:`, error)
showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`)
showError(t('apps.failedAction', { action, error: error.message || 'Unknown error' }))
}
}
@ -151,7 +151,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
setLoading(true)
const response = await api.forceReinstallService(record.service_name)
if (!response) {
throw new Error('An internal error occurred while trying to force reinstall the service.')
throw new Error(t('apps.forceReinstallInternalError'))
}
if (!response.success) {
throw new Error(response.message)
@ -165,7 +165,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
}, 3000)
} catch (error) {
console.error(`Error force reinstalling service ${record.service_name}:`, error)
showError(`Failed to force reinstall service: ${error.message || 'Unknown error'}`)
showError(t('apps.failedForceReinstall', { error: error.message || 'Unknown error' }))
}
}
@ -207,26 +207,21 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
onClick={() => {
openModal(
<StyledModal
title={'Force Reinstall?'}
title={t('apps.forceReinstallTitle')}
onConfirm={() => handleForceReinstall(record)}
onCancel={closeAllModals}
open={true}
confirmText={'Force Reinstall'}
cancelText="Cancel"
confirmText={t('apps.forceReinstall')}
cancelText={t('apps.cancel')}
>
<p className="text-text-primary">
Are you sure you want to force reinstall {record.service_name}? This will{' '}
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
only do this if the service is malfunctioning and other troubleshooting steps have
failed.
</p>
<p className="text-text-primary" dangerouslySetInnerHTML={{ __html: t('apps.forceReinstallConfirm', { name: record.service_name }) }} />
</StyledModal>,
`${record.service_name}-force-reinstall-modal`
)
}}
disabled={isInstalling}
>
Force Reinstall
{t('apps.forceReinstall')}
</StyledButton>
)
@ -241,7 +236,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
disabled={isInstalling || !isOnline}
loading={isInstalling}
>
Install
{t('apps.install')}
</StyledButton>
<ForceReinstallButton />
</div>
@ -256,7 +251,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
window.open(getServiceLink(record.ui_location || 'unknown'), '_blank')
}}
>
Open
{t('apps.open')}
</StyledButton>
{record.available_update_version && (
<StyledButton
@ -265,7 +260,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
onClick={() => handleUpdateService(record)}
disabled={isInstalling || !isOnline}
>
Update
{t('apps.update')}
</StyledButton>
)}
{record.status && record.status !== 'unknown' && (
@ -276,18 +271,17 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
onClick={() => {
openModal(
<StyledModal
title={`${record.status === 'running' ? 'Stop' : 'Start'} Service?`}
title={record.status === 'running' ? t('apps.stopService') : t('apps.startService')}
onConfirm={() =>
handleAffectAction(record, record.status === 'running' ? 'stop' : 'start')
}
onCancel={closeAllModals}
open={true}
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
cancelText="Cancel"
confirmText={record.status === 'running' ? t('apps.stop') : t('apps.start')}
cancelText={t('apps.cancel')}
>
<p className="text-text-primary">
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
{record.service_name}?
{record.status === 'running' ? t('apps.stopConfirm', { name: record.service_name }) : t('apps.startConfirm', { name: record.service_name })}
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
@ -295,7 +289,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
}}
disabled={isInstalling}
>
{record.status === 'running' ? 'Stop' : 'Start'}
{record.status === 'running' ? t('apps.stop') : t('apps.start')}
</StyledButton>
{record.status === 'running' && (
<StyledButton
@ -304,15 +298,15 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
onClick={() => {
openModal(
<StyledModal
title={'Restart Service?'}
title={t('apps.restartService')}
onConfirm={() => handleAffectAction(record, 'restart')}
onCancel={closeAllModals}
open={true}
confirmText={'Restart'}
cancelText="Cancel"
confirmText={t('apps.restart')}
cancelText={t('apps.cancel')}
>
<p className="text-text-primary">
Are you sure you want to restart {record.service_name}?
{t('apps.restartConfirm', { name: record.service_name })}
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
@ -320,7 +314,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
}}
disabled={isInstalling}
>
Restart
{t('apps.restart')}
</StyledButton>
)}
<ForceReinstallButton />
@ -332,14 +326,14 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
return (
<SettingsLayout>
<Head title="App Settings" />
<Head title={t('apps.title')} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-4xl font-semibold">Apps</h1>
<h1 className="text-4xl font-semibold">{t('apps.heading')}</h1>
<p className="text-text-muted mt-1">
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
{t('apps.description')}
</p>
</div>
<StyledButton
@ -348,7 +342,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
disabled={checkingUpdates || !isOnline}
loading={checkingUpdates}
>
Check for Updates
{t('apps.checkForUpdates')}
</StyledButton>
</div>
{loading && <LoadingSpinner fullscreen />}
@ -359,7 +353,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
columns={[
{
accessor: 'friendly_name',
title: 'Name',
title: t('apps.columns.name'),
render(record) {
return (
<div className="flex flex-col">
@ -371,7 +365,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
},
{
accessor: 'ui_location',
title: 'Location',
title: t('apps.columns.location'),
render: (record) => (
<a
href={getServiceLink(record.ui_location || 'unknown')}
@ -385,13 +379,13 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
},
{
accessor: 'installed',
title: 'Installed',
title: t('apps.columns.installed'),
render: (record) =>
record.installed ? <IconCheck className="h-6 w-6 text-desert-green" /> : '',
},
{
accessor: 'container_image',
title: 'Version',
title: t('apps.columns.version'),
render: (record) => {
if (!record.installed) return null
const currentTag = extractTag(record.container_image)

View File

@ -1,5 +1,6 @@
import { Head, Link, usePage } from '@inertiajs/react'
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import CircularGauge from '~/components/systeminfo/CircularGauge'
@ -34,6 +35,7 @@ export default function BenchmarkPage(props: {
currentBenchmarkId: string | null
}
}) {
const { t } = useTranslation()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { subscribe } = useTransmit()
const queryClient = useQueryClient()
@ -82,8 +84,8 @@ export default function BenchmarkPage(props: {
setProgress({
status: 'starting',
progress: 5,
message: 'Starting benchmark... This takes 2-5 minutes.',
current_stage: 'Starting',
message: t('benchmark.progress.starting'),
current_stage: t('benchmark.stages.starting'),
benchmark_id: '',
timestamp: new Date().toISOString(),
})
@ -96,8 +98,8 @@ export default function BenchmarkPage(props: {
setProgress({
status: 'completed',
progress: 100,
message: 'Benchmark completed!',
current_stage: 'Complete',
message: t('benchmark.progress.completed'),
current_stage: t('benchmark.stages.complete'),
benchmark_id: data.benchmark_id,
timestamp: new Date().toISOString(),
})
@ -106,8 +108,8 @@ export default function BenchmarkPage(props: {
setProgress({
status: 'error',
progress: 0,
message: 'Benchmark failed',
current_stage: 'Error',
message: t('benchmark.progress.failed'),
current_stage: t('benchmark.stages.error'),
benchmark_id: '',
timestamp: new Date().toISOString(),
})
@ -118,8 +120,8 @@ export default function BenchmarkPage(props: {
setProgress({
status: 'error',
progress: 0,
message: error.message || 'Benchmark failed',
current_stage: 'Error',
message: error.message || t('benchmark.progress.failed'),
current_stage: t('benchmark.stages.error'),
benchmark_id: '',
timestamp: new Date().toISOString(),
})
@ -179,7 +181,7 @@ export default function BenchmarkPage(props: {
onError: (error: any) => {
// Check if this is a 409 Conflict error (already submitted)
if (error.status === 409) {
setSubmitError('A benchmark for this system with the same or higher score has already been submitted.')
setSubmitError(t('benchmark.alreadySubmitted'))
} else {
setSubmitError(error.message)
}
@ -218,57 +220,57 @@ export default function BenchmarkPage(props: {
{
status: 'detecting_hardware',
progress: 10,
message: 'Detecting system hardware...',
label: 'Detecting Hardware',
message: t('benchmark.progress.detectingHardware'),
label: t('benchmark.stages.detectingHardware'),
duration: 2000,
},
{
status: 'running_cpu',
progress: 25,
message: 'Running CPU benchmark (30s)...',
label: 'CPU Benchmark',
message: t('benchmark.progress.runningCpu'),
label: t('benchmark.stages.cpuBenchmark'),
duration: 32000,
},
{
status: 'running_memory',
progress: 40,
message: 'Running memory benchmark...',
label: 'Memory Benchmark',
message: t('benchmark.progress.runningMemory'),
label: t('benchmark.stages.memoryBenchmark'),
duration: 8000,
},
{
status: 'running_disk_read',
progress: 55,
message: 'Running disk read benchmark (30s)...',
label: 'Disk Read Test',
message: t('benchmark.progress.runningDiskRead'),
label: t('benchmark.stages.diskReadTest'),
duration: 35000,
},
{
status: 'running_disk_write',
progress: 70,
message: 'Running disk write benchmark (30s)...',
label: 'Disk Write Test',
message: t('benchmark.progress.runningDiskWrite'),
label: t('benchmark.stages.diskWriteTest'),
duration: 35000,
},
{
status: 'downloading_ai_model',
progress: 80,
message: 'Downloading AI benchmark model (first run only)...',
label: 'Downloading AI Model',
message: t('benchmark.progress.downloadingAiModel'),
label: t('benchmark.stages.downloadingAiModel'),
duration: 5000,
},
{
status: 'running_ai',
progress: 85,
message: 'Running AI inference benchmark...',
label: 'AI Inference Test',
message: t('benchmark.progress.runningAi'),
label: t('benchmark.stages.aiInferenceTest'),
duration: 15000,
},
{
status: 'calculating_score',
progress: 95,
message: 'Calculating NOMAD score...',
label: 'Calculating Score',
message: t('benchmark.progress.calculatingScore'),
label: t('benchmark.stages.calculatingScore'),
duration: 2000,
},
]
@ -358,13 +360,13 @@ export default function BenchmarkPage(props: {
return (
<SettingsLayout>
<Head title="System Benchmark" />
<Head title={t('benchmark.title')} />
<div className="xl:pl-72 w-full">
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Benchmark</h1>
<h1 className="text-4xl font-bold text-desert-green mb-2">{t('benchmark.heading')}</h1>
<p className="text-desert-stone-dark">
Measure your server's performance and compare with the NOMAD community
{t('benchmark.description')}
</p>
</div>
@ -372,7 +374,7 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Run Benchmark
{t('benchmark.runBenchmark')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
@ -381,7 +383,7 @@ export default function BenchmarkPage(props: {
<div className="flex items-center gap-3">
<div className="animate-spin h-6 w-6 border-2 border-desert-green border-t-transparent rounded-full" />
<span className="text-lg font-medium">
{progress?.current_stage || 'Running benchmark...'}
{progress?.current_stage || t('benchmark.runningBenchmark')}
</span>
</div>
<div className="w-full bg-desert-stone-lighter rounded-full h-4 overflow-hidden">
@ -397,7 +399,7 @@ export default function BenchmarkPage(props: {
{progress?.status === 'error' && (
<Alert
type="error"
title="Benchmark Failed"
title={t('benchmark.benchmarkFailed')}
message={progress.message}
variant="bordered"
dismissible
@ -407,8 +409,8 @@ export default function BenchmarkPage(props: {
{showAIRequiredAlert && (
<Alert
type="warning"
title={`${aiAssistantName} Required`}
message={`Full benchmark requires ${aiAssistantName} to be installed. Install it to measure your complete NOMAD capability and share results with the community.`}
title={t('benchmark.aiRequired', { name: aiAssistantName })}
message={t('benchmark.aiRequiredMessage', { name: aiAssistantName })}
variant="bordered"
dismissible
onDismiss={() => setShowAIRequiredAlert(false)}
@ -417,13 +419,12 @@ export default function BenchmarkPage(props: {
href="/settings/apps"
className="text-sm text-desert-green hover:underline mt-2 inline-block font-medium"
>
Go to Apps to install {aiAssistantName}
{t('benchmark.goToApps', { name: aiAssistantName })}
</Link>
</Alert>
)}
<p className="text-desert-stone-dark">
Run a benchmark to measure your system's CPU, memory, disk, and AI inference
performance. The benchmark takes approximately 2-5 minutes to complete.
{t('benchmark.benchmarkDescription')}
</p>
<div className="flex flex-wrap gap-4">
<StyledButton
@ -431,7 +432,7 @@ export default function BenchmarkPage(props: {
disabled={runBenchmark.isPending}
icon="IconPlayerPlay"
>
Run Full Benchmark
{t('benchmark.runFullBenchmark')}
</StyledButton>
<StyledButton
variant="secondary"
@ -439,7 +440,7 @@ export default function BenchmarkPage(props: {
disabled={runBenchmark.isPending}
icon="IconCpu"
>
System Only
{t('benchmark.systemOnly')}
</StyledButton>
<StyledButton
variant="secondary"
@ -448,24 +449,23 @@ export default function BenchmarkPage(props: {
icon="IconWand"
title={
!aiInstalled
? `${aiAssistantName} must be installed to run AI benchmark`
? t('benchmark.aiOnlyTooltip', { name: aiAssistantName })
: undefined
}
>
AI Only
{t('benchmark.aiOnly')}
</StyledButton>
</div>
{!aiInstalled && (
<p className="text-sm text-desert-stone-dark">
<span className="text-amber-600">Note:</span> {aiAssistantName} is not
installed.
<span className="text-amber-600">Note:</span> {t('benchmark.aiNotInstalledNote', { name: aiAssistantName })}
<Link
href="/settings/apps"
className="text-desert-green hover:underline ml-1"
>
Install it
{t('benchmark.installIt')}
</Link>{' '}
to run full benchmarks and share results with the community.
{t('benchmark.aiNotInstalledSuffix')}
</p>
)}
</div>
@ -479,7 +479,7 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
NOMAD Score
{t('benchmark.nomadScore')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm">
@ -490,7 +490,7 @@ export default function BenchmarkPage(props: {
label="NOMAD Score"
size="lg"
variant="cpu"
subtext="out of 100"
subtext={t('benchmark.outOf100')}
icon={<IconChartBar className="w-8 h-8" />}
/>
</div>
@ -501,22 +501,21 @@ export default function BenchmarkPage(props: {
{latestResult.nomad_score.toFixed(1)}
</div>
<p className="text-desert-stone-dark">
Your NOMAD Score is a weighted composite of all benchmark results.
{t('benchmark.nomadScoreDescription')}
</p>
{/* Share with Community - Only for full benchmarks with AI data */}
{canShareBenchmark && (
<div className="space-y-4 mt-6 pt-6 border-t border-desert-stone-light">
<h3 className="font-semibold text-desert-green">Share with Community</h3>
<h3 className="font-semibold text-desert-green">{t('benchmark.shareWithCommunity')}</h3>
<p className="text-sm text-desert-stone-dark">
Share your benchmark on the community leaderboard. Choose a Builder Tag
to claim your spot, or share anonymously.
{t('benchmark.shareDescription')}
</p>
{/* Builder Tag Selector */}
<div className="space-y-2">
<label className="block text-sm font-medium text-desert-stone-dark">
Your Builder Tag
{t('benchmark.yourBuilderTag')}
</label>
<BuilderTagSelector
value={currentBuilderTag}
@ -535,7 +534,7 @@ export default function BenchmarkPage(props: {
className="w-4 h-4 rounded border-desert-stone-light text-desert-green focus:ring-desert-green"
/>
<span className="text-sm text-desert-stone-dark">
Share anonymously (no Builder Tag shown on leaderboard)
{t('benchmark.shareAnonymously')}
</span>
</label>
@ -549,12 +548,12 @@ export default function BenchmarkPage(props: {
disabled={submitResult.isPending}
icon="IconCloudUpload"
>
{submitResult.isPending ? 'Submitting...' : 'Share with Community'}
{submitResult.isPending ? t('benchmark.submitting') : t('benchmark.shareButton')}
</StyledButton>
{submitError && (
<Alert
type="error"
title="Submission Failed"
title={t('benchmark.submissionFailed')}
message={submitError}
variant="bordered"
dismissible
@ -570,8 +569,8 @@ export default function BenchmarkPage(props: {
!canShareBenchmark && (
<Alert
type="info"
title="Partial Benchmark"
message={`This ${latestResult.benchmark_type} benchmark cannot be shared with the community. Run a Full Benchmark with ${aiAssistantName} installed to share your results.`}
title={t('benchmark.partialBenchmark')}
message={t('benchmark.partialBenchmarkMessage', { type: latestResult.benchmark_type, name: aiAssistantName })}
variant="bordered"
/>
)}
@ -579,8 +578,8 @@ export default function BenchmarkPage(props: {
{latestResult.submitted_to_repository && (
<Alert
type="success"
title="Shared with Community"
message="Your benchmark has been submitted to the community leaderboard. Thanks for contributing!"
title={t('benchmark.sharedWithCommunity')}
message={t('benchmark.sharedMessage')}
variant="bordered"
>
<a
@ -589,7 +588,7 @@ export default function BenchmarkPage(props: {
rel="noopener noreferrer"
className="text-sm text-desert-green hover:underline mt-2 inline-block"
>
View the leaderboard
{t('benchmark.viewLeaderboard')}
</a>
</Alert>
)}
@ -601,14 +600,14 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
System Performance
{t('benchmark.systemPerformance')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<CircularGauge
value={latestResult.cpu_score * 100}
label="CPU"
label={t('benchmark.cpu')}
size="md"
variant="cpu"
icon={<IconCpu className="w-6 h-6" />}
@ -617,7 +616,7 @@ export default function BenchmarkPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<CircularGauge
value={latestResult.memory_score * 100}
label="Memory"
label={t('benchmark.memory')}
size="md"
variant="memory"
icon={<IconDatabase className="w-6 h-6" />}
@ -626,7 +625,7 @@ export default function BenchmarkPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<CircularGauge
value={latestResult.disk_read_score * 100}
label="Disk Read"
label={t('benchmark.diskRead')}
size="md"
variant="disk"
icon={<IconServer className="w-6 h-6" />}
@ -635,7 +634,7 @@ export default function BenchmarkPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<CircularGauge
value={latestResult.disk_write_score * 100}
label="Disk Write"
label={t('benchmark.diskWrite')}
size="md"
variant="disk"
icon={<IconServer className="w-6 h-6" />}
@ -648,7 +647,7 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
AI Performance
{t('benchmark.aiPerformance')}
</h2>
{latestResult.ai_tokens_per_second ? (
@ -656,7 +655,7 @@ export default function BenchmarkPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<CircularGauge
value={getAIScore(latestResult.ai_tokens_per_second)}
label="AI Score"
label={t('benchmark.aiScore')}
size="md"
variant="cpu"
icon={<IconRobot className="w-6 h-6" />}
@ -670,8 +669,8 @@ export default function BenchmarkPage(props: {
{latestResult.ai_tokens_per_second.toFixed(1)}
</div>
<div className="text-sm text-desert-stone-dark flex items-center gap-1">
Tokens per Second
<InfoTooltip text="How fast the AI generates text. Higher is better. 30+ tokens/sec feels responsive, 60+ feels instant." />
{t('benchmark.tokensPerSecond')}
<InfoTooltip text={t('benchmark.tokensPerSecondTooltip')} />
</div>
</div>
</div>
@ -684,8 +683,8 @@ export default function BenchmarkPage(props: {
{latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms
</div>
<div className="text-sm text-desert-stone-dark flex items-center gap-1">
Time to First Token
<InfoTooltip text="How quickly the AI starts responding after you send a message. Lower is better. Under 500ms feels instant." />
{t('benchmark.timeToFirstToken')}
<InfoTooltip text={t('benchmark.timeToFirstTokenTooltip')} />
</div>
</div>
</div>
@ -695,10 +694,9 @@ export default function BenchmarkPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm">
<div className="text-center text-desert-stone-dark">
<IconRobot className="w-12 h-12 mx-auto mb-3 opacity-40" />
<p className="font-medium">No AI Benchmark Data</p>
<p className="font-medium">{t('benchmark.noAIData')}</p>
<p className="text-sm mt-1">
Run a Full Benchmark or AI Only benchmark to measure AI inference
performance.
{t('benchmark.noAIDataMessage')}
</p>
</div>
</div>
@ -708,28 +706,28 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Hardware Information
{t('benchmark.hardwareInformation')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<InfoCard
title="Processor"
title={t('benchmark.processor')}
icon={<IconCpu className="w-6 h-6" />}
variant="elevated"
data={[
{ label: 'Model', value: latestResult.cpu_model },
{ label: 'Cores', value: latestResult.cpu_cores },
{ label: 'Threads', value: latestResult.cpu_threads },
{ label: t('benchmark.modelLabel'), value: latestResult.cpu_model },
{ label: t('benchmark.cores'), value: latestResult.cpu_cores },
{ label: t('benchmark.threads'), value: latestResult.cpu_threads },
]}
/>
<InfoCard
title="System"
title={t('benchmark.systemLabel')}
icon={<IconServer className="w-6 h-6" />}
variant="elevated"
data={[
{ label: 'RAM', value: formatBytes(latestResult.ram_bytes) },
{ label: 'Disk Type', value: latestResult.disk_type.toUpperCase() },
{ label: 'GPU', value: latestResult.gpu_model || 'Not detected' },
{ label: t('benchmark.ram'), value: formatBytes(latestResult.ram_bytes) },
{ label: t('benchmark.diskType'), value: latestResult.disk_type.toUpperCase() },
{ label: t('benchmark.gpu'), value: latestResult.gpu_model || t('benchmark.notDetected') },
]}
/>
</div>
@ -738,7 +736,7 @@ export default function BenchmarkPage(props: {
<section>
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Benchmark Details
{t('benchmark.benchmarkDetails')}
</h2>
<div className="bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden">
@ -749,17 +747,17 @@ export default function BenchmarkPage(props: {
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-left flex-1">
<div>
<div className="text-desert-stone-dark">Benchmark ID</div>
<div className="text-desert-stone-dark">{t('benchmark.benchmarkId')}</div>
<div className="font-mono text-xs">
{latestResult.benchmark_id.slice(0, 8)}...
</div>
</div>
<div>
<div className="text-desert-stone-dark">Type</div>
<div className="text-desert-stone-dark">{t('benchmark.type')}</div>
<div className="capitalize">{latestResult.benchmark_type}</div>
</div>
<div>
<div className="text-desert-stone-dark">Date</div>
<div className="text-desert-stone-dark">{t('benchmark.date')}</div>
<div>
{new Date(
latestResult.created_at as unknown as string
@ -767,7 +765,7 @@ export default function BenchmarkPage(props: {
</div>
</div>
<div>
<div className="text-desert-stone-dark">NOMAD Score</div>
<div className="text-desert-stone-dark">{t('benchmark.nomadScore')}</div>
<div className="font-bold text-desert-green">
{latestResult.nomad_score.toFixed(1)}
</div>
@ -784,28 +782,28 @@ export default function BenchmarkPage(props: {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Raw Scores */}
<div>
<h4 className="font-semibold text-desert-green mb-3">Raw Scores</h4>
<h4 className="font-semibold text-desert-green mb-3">{t('benchmark.rawScores')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-desert-stone-dark">CPU Score</span>
<span className="text-desert-stone-dark">{t('benchmark.cpuScore')}</span>
<span className="font-mono">
{(latestResult.cpu_score * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Memory Score</span>
<span className="text-desert-stone-dark">{t('benchmark.memoryScore')}</span>
<span className="font-mono">
{(latestResult.memory_score * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Disk Read Score</span>
<span className="text-desert-stone-dark">{t('benchmark.diskReadScore')}</span>
<span className="font-mono">
{(latestResult.disk_read_score * 100).toFixed(1)}%
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Disk Write Score</span>
<span className="text-desert-stone-dark">{t('benchmark.diskWriteScore')}</span>
<span className="font-mono">
{(latestResult.disk_write_score * 100).toFixed(1)}%
</span>
@ -813,14 +811,14 @@ export default function BenchmarkPage(props: {
{latestResult.ai_tokens_per_second && (
<>
<div className="flex justify-between">
<span className="text-desert-stone-dark">AI Tokens/sec</span>
<span className="text-desert-stone-dark">{t('benchmark.aiTokensSec')}</span>
<span className="font-mono">
{latestResult.ai_tokens_per_second.toFixed(1)}
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">
AI Time to First Token
{t('benchmark.aiTimeToFirstToken')}
</span>
<span className="font-mono">
{latestResult.ai_time_to_first_token?.toFixed(0) || 'N/A'} ms
@ -833,18 +831,18 @@ export default function BenchmarkPage(props: {
{/* Benchmark Info */}
<div>
<h4 className="font-semibold text-desert-green mb-3">Benchmark Info</h4>
<h4 className="font-semibold text-desert-green mb-3">{t('benchmark.benchmarkInfo')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-desert-stone-dark">Full Benchmark ID</span>
<span className="text-desert-stone-dark">{t('benchmark.fullBenchmarkId')}</span>
<span className="font-mono text-xs">{latestResult.benchmark_id}</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Benchmark Type</span>
<span className="text-desert-stone-dark">{t('benchmark.benchmarkType')}</span>
<span className="capitalize">{latestResult.benchmark_type}</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Run Date</span>
<span className="text-desert-stone-dark">{t('benchmark.runDate')}</span>
<span>
{new Date(
latestResult.created_at as unknown as string
@ -852,26 +850,26 @@ export default function BenchmarkPage(props: {
</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Builder Tag</span>
<span className="text-desert-stone-dark">{t('benchmark.builderTag')}</span>
<span className="font-mono">
{latestResult.builder_tag || 'Not set'}
{latestResult.builder_tag || t('benchmark.notSet')}
</span>
</div>
{latestResult.ai_model_used && (
<div className="flex justify-between">
<span className="text-desert-stone-dark">AI Model Used</span>
<span className="text-desert-stone-dark">{t('benchmark.aiModelUsed')}</span>
<span>{latestResult.ai_model_used}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-desert-stone-dark">
Submitted to Repository
{t('benchmark.submittedToRepository')}
</span>
<span>{latestResult.submitted_to_repository ? 'Yes' : 'No'}</span>
<span>{latestResult.submitted_to_repository ? t('benchmark.yes') : t('benchmark.no')}</span>
</div>
{latestResult.repository_id && (
<div className="flex justify-between">
<span className="text-desert-stone-dark">Repository ID</span>
<span className="text-desert-stone-dark">{t('benchmark.repositoryId')}</span>
<span className="font-mono text-xs">
{latestResult.repository_id}
</span>
@ -890,7 +888,7 @@ export default function BenchmarkPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Benchmark History
{t('benchmark.benchmarkHistory')}
</h2>
<div className="bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden">
@ -901,8 +899,7 @@ export default function BenchmarkPage(props: {
<div className="flex items-center gap-2">
<IconClock className="w-5 h-5 text-desert-stone-dark" />
<span className="font-medium text-desert-green">
{benchmarkHistory.length} benchmark
{benchmarkHistory.length !== 1 ? 's' : ''} recorded
{t('benchmark.benchmarksRecorded', { count: benchmarkHistory.length, plural: benchmarkHistory.length !== 1 ? 's' : '' })}
</span>
</div>
<IconChevronDown
@ -917,19 +914,19 @@ export default function BenchmarkPage(props: {
<thead className="bg-desert-stone-lighter/50">
<tr>
<th className="text-left p-3 font-medium text-desert-stone-dark">
Date
{t('benchmark.date')}
</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">
Type
{t('benchmark.type')}
</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">
Score
{t('benchmark.score')}
</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">
Builder Tag
{t('benchmark.builderTag')}
</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">
Shared
{t('benchmark.shared')}
</th>
</tr>
</thead>
@ -980,8 +977,8 @@ export default function BenchmarkPage(props: {
{!latestResult && !isRunning && (
<Alert
type="info"
title="No Benchmark Results"
message="Run your first benchmark to see your server's performance scores."
title={t('benchmark.noBenchmarkResults')}
message={t('benchmark.noBenchmarkResultsMessage')}
variant="bordered"
/>
)}

View File

@ -1,70 +1,65 @@
import { Head } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import SettingsLayout from '~/layouts/SettingsLayout'
export default function LegalPage() {
const { t } = useTranslation()
return (
<SettingsLayout>
<Head title="Legal Notices | Project N.O.M.A.D." />
<Head title={t('legal.title')} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6 max-w-4xl">
<h1 className="text-4xl font-semibold mb-8">Legal Notices</h1>
<h1 className="text-4xl font-semibold mb-8">{t('legal.heading')}</h1>
{/* License Agreement */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">License Agreement</h2>
<p className="text-text-primary mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
<h2 className="text-2xl font-semibold mb-4">{t('legal.licenseAgreement')}</h2>
<p className="text-text-primary mb-3">{t('legal.copyright')}</p>
<p className="text-text-primary mb-3">
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
{t('legal.licenseText1')}
</p>
<p className="text-text-primary mb-3">
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a>
</p>
<p className="text-text-primary">
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
{t('legal.licenseText2')}
</p>
</section>
{/* Third-Party Software */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2>
<h2 className="text-2xl font-semibold mb-4">{t('legal.thirdParty')}</h2>
<p className="text-text-primary mb-4">
Project N.O.M.A.D. integrates the following open source projects. We are grateful to
their developers and communities:
{t('legal.thirdPartyIntro')}
</p>
<ul className="space-y-3 text-text-primary">
<li>
<strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)
<strong>Kiwix</strong> - {t('legal.kiwixDescription')}
<br />
<a href="https://kiwix.org" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://kiwix.org</a>
</li>
<li>
<strong>Kolibri</strong> - Offline learning platform by Learning Equality (MIT License)
<strong>Kolibri</strong> - {t('legal.kolibriDescription')}
<br />
<a href="https://learningequality.org/kolibri" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://learningequality.org/kolibri</a>
</li>
<li>
<strong>Ollama</strong> - Local large language model runtime (MIT License)
<strong>Ollama</strong> - {t('legal.ollamaDescription')}
<br />
<a href="https://ollama.com" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://ollama.com</a>
</li>
<li>
<strong>CyberChef</strong> - Data analysis and encoding toolkit by GCHQ (Apache 2.0 License)
<strong>CyberChef</strong> - {t('legal.cyberchefDescription')}
<br />
<a href="https://github.com/gchq/CyberChef" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://github.com/gchq/CyberChef</a>
</li>
<li>
<strong>FlatNotes</strong> - Self-hosted note-taking application (MIT License)
<strong>FlatNotes</strong> - {t('legal.flatnotesDescription')}
<br />
<a href="https://github.com/dullage/flatnotes" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://github.com/dullage/flatnotes</a>
</li>
<li>
<strong>Qdrant</strong> - Vector search engine for AI knowledge base (Apache 2.0 License)
<strong>Qdrant</strong> - {t('legal.qdrantDescription')}
<br />
<a href="https://github.com/qdrant/qdrant" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://github.com/qdrant/qdrant</a>
</li>
@ -73,70 +68,62 @@ export default function LegalPage() {
{/* Privacy Statement */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2>
<h2 className="text-2xl font-semibold mb-4">{t('legal.privacyStatement')}</h2>
<p className="text-text-primary mb-3">
Project N.O.M.A.D. is designed with privacy as a core principle:
{t('legal.privacyIntro')}
</p>
<ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>
<li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>
<li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>
<li><strong>Network Optional:</strong> An internet connection is only required to download content or updates. All installed features work fully offline.</li>
<li><strong>{t('legal.zeroTelemetry')}</strong> {t('legal.zeroTelemetryText')}</li>
<li><strong>{t('legal.localFirst')}</strong> {t('legal.localFirstText')}</li>
<li><strong>{t('legal.noAccounts')}</strong> {t('legal.noAccountsText')}</li>
<li><strong>{t('legal.networkOptional')}</strong> {t('legal.networkOptionalText')}</li>
</ul>
</section>
{/* Content Disclaimer */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2>
<h2 className="text-2xl font-semibold mb-4">{t('legal.contentDisclaimer')}</h2>
<p className="text-text-primary mb-3">
Project N.O.M.A.D. provides tools to download and access content from third-party sources
including Wikipedia, Wikibooks, medical references, educational platforms, and other
publicly available resources.
{t('legal.contentDisclaimerText1')}
</p>
<p className="text-text-primary mb-3">
Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,
completeness, or reliability of any third-party content. The inclusion of any content
does not constitute an endorsement.
{t('legal.contentDisclaimerText2')}
</p>
<p className="text-text-primary">
Users are responsible for evaluating the appropriateness and accuracy of any content
they download and use.
{t('legal.contentDisclaimerText3')}
</p>
</section>
{/* Medical Disclaimer */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2>
<h2 className="text-2xl font-semibold mb-4">{t('legal.medicalDisclaimer')}</h2>
<p className="text-text-primary mb-3">
Some content available through N.O.M.A.D. includes medical references, first aid guides,
and emergency preparedness information. This content is provided for general
informational purposes only.
{t('legal.medicalDisclaimerText1')}
</p>
<p className="text-text-primary mb-3 font-semibold">
This information is NOT a substitute for professional medical advice, diagnosis, or treatment.
{t('legal.medicalDisclaimerText2')}
</p>
<ul className="list-disc list-inside space-y-2 text-text-primary mb-3">
<li>Always seek the advice of qualified health providers with questions about medical conditions.</li>
<li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>
<li>In a medical emergency, call emergency services immediately if available.</li>
<li>Medical information may become outdated. Verify critical information with current professional sources when possible.</li>
<li>{t('legal.medicalPoint1')}</li>
<li>{t('legal.medicalPoint2')}</li>
<li>{t('legal.medicalPoint3')}</li>
<li>{t('legal.medicalPoint4')}</li>
</ul>
</section>
{/* Data Storage Notice */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Data Storage</h2>
<h2 className="text-2xl font-semibold mb-4">{t('legal.dataStorage')}</h2>
<p className="text-text-primary mb-3">
All data associated with Project N.O.M.A.D. is stored locally on your device:
{t('legal.dataStorageIntro')}
</p>
<ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Installation Directory:</strong> /opt/project-nomad</li>
<li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>
<li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>
<li><strong>{t('legal.installationDirectory')}</strong> /opt/project-nomad</li>
<li><strong>{t('legal.downloadedContent')}</strong> /opt/project-nomad/storage</li>
<li><strong>{t('legal.applicationData')}</strong> {t('legal.applicationDataValue')}</li>
</ul>
<p className="text-text-primary mt-3">
You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these
directories will permanently remove all associated data.
{t('legal.dataStorageNote')}
</p>
</section>

View File

@ -1,4 +1,5 @@
import { Head, router } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import StyledButton from '~/components/StyledButton'
@ -22,6 +23,7 @@ const CURATED_COLLECTIONS_KEY = 'curated-map-collections'
export default function MapsManager(props: {
maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] }
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const { addNotification } = useNotifications()
@ -44,13 +46,13 @@ export default function MapsManager(props: {
const res = await api.downloadBaseMapAssets()
if (!res) {
throw new Error('An unknown error occurred while downloading base assets.')
throw new Error(t('mapsManager.baseAssetsUnknownError'))
}
if (res.success) {
addNotification({
type: 'success',
message: 'Base map assets downloaded successfully.',
message: t('mapsManager.baseAssetsSuccess'),
})
router.reload()
}
@ -58,7 +60,7 @@ export default function MapsManager(props: {
console.error('Error downloading base assets:', error)
addNotification({
type: 'error',
message: 'An error occurred while downloading the base map assets. Please try again.',
message: t('mapsManager.baseAssetsError'),
})
} finally {
setDownloading(false)
@ -71,7 +73,7 @@ export default function MapsManager(props: {
invalidateDownloads()
addNotification({
type: 'success',
message: `Download for collection "${record.name}" has been queued.`,
message: t('mapsManager.downloadQueued', { name: record.name }),
})
} catch (error) {
console.error('Error downloading collection:', error)
@ -84,7 +86,7 @@ export default function MapsManager(props: {
invalidateDownloads()
addNotification({
type: 'success',
message: 'Download has been queued.',
message: t('mapsManager.customDownloadQueued'),
})
} catch (error) {
console.error('Error downloading custom file:', error)
@ -94,18 +96,18 @@ export default function MapsManager(props: {
async function confirmDeleteFile(file: FileEntry) {
openModal(
<StyledModal
title="Confirm Delete?"
title={t('mapsManager.confirmDelete')}
onConfirm={() => {
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Delete"
cancelText="Cancel"
confirmText={t('mapsManager.delete')}
cancelText={t('mapsManager.cancel')}
confirmVariant="danger"
>
<p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone.
{t('mapsManager.confirmDeleteMessage', { name: file.name })}
</p>
</StyledModal>,
'confirm-delete-file-modal'
@ -116,12 +118,12 @@ export default function MapsManager(props: {
const isCollection = 'resources' in record
openModal(
<StyledModal
title="Confirm Download?"
title={t('mapsManager.confirmDownload')}
onConfirm={() => {
if (isCollection) {
if (record.all_installed) {
addNotification({
message: `All resources in the collection "${record.name}" have already been downloaded.`,
message: t('mapsManager.allResourcesDownloaded', { name: record.name }),
type: 'info',
})
return
@ -132,15 +134,11 @@ export default function MapsManager(props: {
}}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmText={t('mapsManager.download')}
cancelText={t('mapsManager.cancel')}
confirmVariant="primary"
>
<p className="text-text-secondary">
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
It may take some time for it to be available depending on the file size and your internet
connection.
</p>
<p className="text-text-secondary" dangerouslySetInnerHTML={{ __html: t('mapsManager.confirmDownloadMessage', { name: isCollection ? record.name : record }) }} />
</StyledModal>,
'confirm-download-file-modal'
)
@ -149,7 +147,7 @@ export default function MapsManager(props: {
async function openDownloadModal() {
openModal(
<DownloadURLModal
title="Download Map File"
title={t('mapsManager.downloadMapFileTitle')}
suggestedURL="e.g. https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/california.pmtiles"
onCancel={() => closeAllModals()}
onPreflightSuccess={async (url) => {
@ -165,7 +163,7 @@ export default function MapsManager(props: {
mutationFn: () => api.refreshManifests(),
onSuccess: () => {
addNotification({
message: 'Successfully refreshed map collections.',
message: t('mapsManager.refreshSuccess'),
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_COLLECTIONS_KEY] })
@ -174,13 +172,13 @@ export default function MapsManager(props: {
return (
<SettingsLayout>
<Head title="Maps Manager" />
<Head title={t('mapsManager.title')} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
<p className="text-text-muted">Manage your stored map files and explore new regions!</p>
<h1 className="text-4xl font-semibold mb-2">{t('mapsManager.heading')}</h1>
<p className="text-text-muted">{t('mapsManager.description')}</p>
</div>
<div className="flex space-x-4">
@ -188,13 +186,13 @@ export default function MapsManager(props: {
</div>
{!props.maps.baseAssetsExist && (
<Alert
title="The base map assets have not been installed. Please download them first to enable map functionality."
title={t('mapsManager.baseAssetsAlert')}
type="warning"
variant="solid"
className="my-4"
buttonProps={{
variant: 'secondary',
children: 'Download Base Assets',
children: t('mapsManager.downloadBaseAssets'),
icon: 'IconDownload',
loading: downloading,
onClick: () => downloadBaseAssets(),
@ -202,13 +200,13 @@ export default function MapsManager(props: {
/>
)}
<div className="mt-8 mb-6 flex items-center justify-between">
<StyledSectionHeader title="Curated Map Regions" className="!mb-0" />
<StyledSectionHeader title={t('mapsManager.curatedMapRegions')} className="!mb-0" />
<StyledButton
onClick={() => refreshManifests.mutate()}
disabled={refreshManifests.isPending}
icon="IconRefresh"
>
Force Refresh Collections
{t('mapsManager.forceRefreshCollections')}
</StyledButton>
</div>
<div className="!mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
@ -220,18 +218,18 @@ export default function MapsManager(props: {
/>
))}
{curatedCollections && curatedCollections.length === 0 && (
<p className="text-text-muted">No curated collections available.</p>
<p className="text-text-muted">{t('mapsManager.noCuratedCollections')}</p>
)}
</div>
<div className="mt-12 mb-6 flex items-center justify-between">
<StyledSectionHeader title="Stored Map Files" className="!mb-0" />
<StyledSectionHeader title={t('mapsManager.storedMapFiles')} className="!mb-0" />
<StyledButton
variant="primary"
onClick={openDownloadModal}
loading={downloading}
icon="IconCloudDownload"
>
Download a Custom Map File
{t('mapsManager.downloadCustomMapFile')}
</StyledButton>
</div>
<StyledTable<FileEntry & { actions?: any }>
@ -240,10 +238,10 @@ export default function MapsManager(props: {
loading={false}
compact
columns={[
{ accessor: 'name', title: 'Name' },
{ accessor: 'name', title: t('mapsManager.columns.name') },
{
accessor: 'actions',
title: 'Actions',
title: t('mapsManager.columns.actions'),
render: (record) => (
<div className="flex space-x-2">
<StyledButton
@ -253,7 +251,7 @@ export default function MapsManager(props: {
confirmDeleteFile(record)
}}
>
Delete
{t('mapsManager.delete')}
</StyledButton>
</div>
),

View File

@ -1,5 +1,6 @@
import { Head, router, usePage } from '@inertiajs/react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { NomadOllamaModel } from '../../../types/ollama'
@ -34,6 +35,7 @@ export default function ModelsPage(props: {
const { openModal, closeAllModals } = useModals()
const { debounce } = useDebounce()
const { data: systemInfo } = useSystemInfo({})
const { t } = useTranslation()
const [gpuBannerDismissed, setGpuBannerDismissed] = useState(() => {
try {
@ -54,7 +56,7 @@ export default function ModelsPage(props: {
const handleForceReinstallOllama = () => {
openModal(
<StyledModal
title="Reinstall AI Assistant?"
title={t('models.reinstallTitle')}
onConfirm={async () => {
closeAllModals()
setReinstalling(true)
@ -64,14 +66,14 @@ export default function ModelsPage(props: {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: `${aiAssistantName} is being reinstalled with GPU support. This page will reload shortly.`,
message: t('models.reinstallSuccess', { name: aiAssistantName }),
type: 'success',
})
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
message: t('models.reinstallFailed', { error: error instanceof Error ? error.message : 'Unknown error' }),
type: 'error',
})
setReinstalling(false)
@ -79,14 +81,10 @@ export default function ModelsPage(props: {
}}
onCancel={closeAllModals}
open={true}
confirmText="Reinstall"
cancelText="Cancel"
confirmText={t('models.reinstall')}
cancelText={t('models.cancel')}
>
<p className="text-text-primary">
This will recreate the {aiAssistantName} container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
</p>
<p className="text-text-primary">{t('models.reinstallMessage', { name: aiAssistantName })}</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
)
@ -136,7 +134,7 @@ export default function ModelsPage(props: {
setIsForceRefreshing(true)
await refetch()
setIsForceRefreshing(false)
addNotification({ message: 'Model list refreshed from remote.', type: 'success' })
addNotification({ message: t('models.refreshSuccess'), type: 'success' })
}
async function handleInstallModel(modelName: string) {
@ -144,14 +142,14 @@ export default function ModelsPage(props: {
const res = await api.downloadModel(modelName)
if (res.success) {
addNotification({
message: `Model download initiated for ${modelName}. It may take some time to complete.`,
message: t('models.downloadInitiated', { name: modelName }),
type: 'success',
})
}
} catch (error) {
console.error('Error installing model:', error)
addNotification({
message: `There was an error installing the model: ${modelName}. Please try again.`,
message: t('models.downloadError', { name: modelName }),
type: 'error',
})
}
@ -162,7 +160,7 @@ export default function ModelsPage(props: {
const res = await api.deleteModel(modelName)
if (res.success) {
addNotification({
message: `Model deleted: ${modelName}.`,
message: t('models.deleteSuccess', { name: modelName }),
type: 'success',
})
}
@ -171,7 +169,7 @@ export default function ModelsPage(props: {
} catch (error) {
console.error('Error deleting model:', error)
addNotification({
message: `There was an error deleting the model: ${modelName}. Please try again.`,
message: t('models.deleteError', { name: modelName }),
type: 'error',
})
}
@ -180,19 +178,18 @@ export default function ModelsPage(props: {
async function confirmDeleteModel(model: string) {
openModal(
<StyledModal
title="Delete Model?"
title={t('models.deleteModelTitle')}
onConfirm={() => {
handleDeleteModel(model)
}}
onCancel={closeAllModals}
open={true}
confirmText="Delete"
cancelText="Cancel"
confirmText={t('models.delete')}
cancelText={t('models.cancel')}
confirmVariant="primary"
>
<p className="text-text-primary">
Are you sure you want to delete this model? You will need to download it again if you want
to use it in the future.
{t('models.deleteModelMessage')}
</p>
</StyledModal>,
'confirm-delete-model-modal'
@ -205,14 +202,14 @@ export default function ModelsPage(props: {
},
onSuccess: () => {
addNotification({
message: 'Setting updated successfully.',
message: t('models.settingUpdated'),
type: 'success',
})
},
onError: (error) => {
console.error('Error updating setting:', error)
addNotification({
message: 'There was an error updating the setting. Please try again.',
message: t('models.settingUpdateError'),
type: 'error',
})
},
@ -220,18 +217,16 @@ export default function ModelsPage(props: {
return (
<SettingsLayout>
<Head title={`${aiAssistantName} Settings | Project N.O.M.A.D.`} />
<Head title={t('models.title', { name: aiAssistantName })} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
<p className="text-text-muted mb-4">
Easily manage the {aiAssistantName}'s settings and installed models. We recommend
starting with smaller models first to see how they perform on your system before moving
on to larger ones.
{t('models.description', { name: aiAssistantName })}
</p>
{!isInstalled && (
<Alert
title={`${aiAssistantName}'s dependencies are not installed. Please install them to manage AI models.`}
title={t('models.notInstalled', { name: aiAssistantName })}
type="warning"
variant="solid"
className="!mt-6"
@ -241,13 +236,13 @@ export default function ModelsPage(props: {
<Alert
type="warning"
variant="bordered"
title="GPU Not Accessible"
message={`Your system has an NVIDIA GPU, but ${aiAssistantName} can't access it. AI is running on CPU only, which is significantly slower.`}
title={t('models.gpuNotAccessible')}
message={t('models.gpuNotAccessibleMessage', { name: aiAssistantName })}
className="!mt-6"
dismissible={true}
onDismiss={handleDismissGpuBanner}
buttonProps={{
children: `Fix: Reinstall ${aiAssistantName}`,
children: t('models.fixReinstall', { name: aiAssistantName }),
icon: 'IconRefresh',
variant: 'action',
size: 'sm',
@ -258,7 +253,7 @@ export default function ModelsPage(props: {
/>
)}
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
<StyledSectionHeader title={t('models.settings')} className="mt-8 mb-4" />
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<div className="space-y-4">
<Switch
@ -267,14 +262,14 @@ export default function ModelsPage(props: {
setChatSuggestionsEnabled(newVal)
updateSettingMutation.mutate({ key: 'chat.suggestionsEnabled', value: newVal })
}}
label="Chat Suggestions"
description="Display AI-generated conversation starters in the chat interface"
label={t('models.chatSuggestions')}
description={t('models.chatSuggestionsDescription')}
/>
<Input
name="aiAssistantCustomName"
label="Assistant Name"
helpText='Give your AI assistant a custom name that will be used in the chat interface and other areas of the application.'
placeholder="AI Assistant"
label={t('models.assistantName')}
helpText={t('models.assistantNameHelp')}
placeholder={t('models.assistantNamePlaceholder')}
value={aiAssistantCustomName}
onChange={(e) => setAiAssistantCustomName(e.target.value)}
onBlur={() =>
@ -288,12 +283,12 @@ export default function ModelsPage(props: {
</div>
<ActiveModelDownloads withHeader />
<StyledSectionHeader title="Models" className="mt-12 mb-4" />
<StyledSectionHeader title={t('models.modelsHeading')} className="mt-12 mb-4" />
<div className="flex justify-start items-center gap-3 mt-4">
<Input
name="search"
label=""
placeholder="Search language models.."
placeholder={t('models.searchPlaceholder')}
value={queryUI}
onChange={(e) => {
setQueryUI(e.target.value)
@ -309,7 +304,7 @@ export default function ModelsPage(props: {
loading={isForceRefreshing}
className='mt-1'
>
Refresh Models
{t('models.refreshModels')}
</StyledButton>
</div>
<StyledTable<NomadOllamaModel>
@ -318,7 +313,7 @@ export default function ModelsPage(props: {
columns={[
{
accessor: 'name',
title: 'Name',
title: t('models.columns.name'),
render(record) {
return (
<div className="flex flex-col">
@ -330,11 +325,11 @@ export default function ModelsPage(props: {
},
{
accessor: 'estimated_pulls',
title: 'Estimated Pulls',
title: t('models.columns.estimatedPulls'),
},
{
accessor: 'model_last_updated',
title: 'Last Updated',
title: t('models.columns.lastUpdated'),
},
]}
data={availableModelData?.models || []}
@ -347,19 +342,19 @@ export default function ModelsPage(props: {
<thead className="bg-surface-primary">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Tag
{t('models.columns.tag')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Input Type
{t('models.columns.inputType')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Context Size
{t('models.columns.contextSize')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Model Size
{t('models.columns.modelSize')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action
{t('models.columns.action')}
</th>
</tr>
</thead>
@ -398,7 +393,7 @@ export default function ModelsPage(props: {
}}
icon={isInstalled ? 'IconTrash' : 'IconDownload'}
>
{isInstalled ? 'Delete' : 'Install'}
{isInstalled ? t('models.delete') : t('models.install')}
</StyledButton>
</td>
</tr>
@ -419,7 +414,7 @@ export default function ModelsPage(props: {
setLimit((prev) => prev + 15)
}}
>
Load More
{t('models.loadMore')}
</StyledButton>
)}
</div>

View File

@ -1,25 +1,26 @@
import { Head } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import { IconExternalLink } from '@tabler/icons-react'
import SettingsLayout from '~/layouts/SettingsLayout'
export default function SupportPage() {
const { t } = useTranslation()
return (
<SettingsLayout>
<Head title="Support the Project | Project N.O.M.A.D." />
<Head title={`${t('support.title')} | Project N.O.M.A.D.`} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6 max-w-4xl">
<h1 className="text-4xl font-semibold mb-4">Support the Project</h1>
<h1 className="text-4xl font-semibold mb-4">{t('support.title')}</h1>
<p className="text-text-muted mb-10 text-lg">
Project NOMAD is 100% free and open source no subscriptions, no paywalls, no catch.
If you'd like to help keep the project going, here are a few ways to show your support.
{t('support.subtitle')}
</p>
{/* Ko-fi */}
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Buy Us a Coffee</h2>
<h2 className="text-2xl font-semibold mb-3">{t('support.kofiTitle')}</h2>
<p className="text-text-muted mb-4">
Every contribution helps fund development, server costs, and new content packs for NOMAD.
Even a small donation goes a long way.
{t('support.kofiDescription')}
</p>
<a
href="https://ko-fi.com/crosstalk"
@ -27,14 +28,14 @@ export default function SupportPage() {
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#FF5E5B] hover:bg-[#e54e4b] text-white font-semibold rounded-lg transition-colors"
>
Support on Ko-fi
{t('support.kofiButton')}
<IconExternalLink size={18} />
</a>
</section>
{/* Rogue Support */}
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Need Help With Your Home Network?</h2>
<h2 className="text-2xl font-semibold mb-3">{t('support.rogueTitle')}</h2>
<a
href="https://roguesupport.com"
target="_blank"
@ -43,13 +44,12 @@ export default function SupportPage() {
>
<img
src="/rogue-support-banner.png"
alt="Rogue Support — Conquer Your Home Network"
alt={t('support.rogueBannerAlt')}
className="w-full"
/>
</a>
<p className="text-text-muted mb-4">
Rogue Support is a networking consultation service for home users.
Think of it as Uber for computer networking expert help when you need it.
{t('support.rogueDescription')}
</p>
<a
href="https://roguesupport.com"
@ -57,14 +57,14 @@ export default function SupportPage() {
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-medium"
>
Visit RogueSupport.com
{t('support.rogueButton')}
<IconExternalLink size={16} />
</a>
</section>
{/* Other Ways to Help */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-3">Other Ways to Help</h2>
<h2 className="text-2xl font-semibold mb-3">{t('support.otherTitle')}</h2>
<ul className="space-y-2 text-text-muted">
<li>
<a
@ -73,9 +73,9 @@ export default function SupportPage() {
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Star the project on GitHub
{t('support.starOnGithub')}
</a>
{' '} it helps more people discover NOMAD
{' '} {t('support.starOnGithubSuffix')}
</li>
<li>
<a
@ -84,11 +84,11 @@ export default function SupportPage() {
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Report bugs and suggest features
{t('support.reportBugs')}
</a>
{' '} every report makes NOMAD better
{' '} {t('support.reportBugsSuffix')}
</li>
<li>Share NOMAD with someone who'd use it word of mouth is the best marketing</li>
<li>{t('support.shareNomad')}</li>
<li>
<a
href="https://discord.com/invite/crosstalksolutions"
@ -96,9 +96,9 @@ export default function SupportPage() {
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Join the Discord community
{t('support.joinDiscord')}
</a>
{' '} hang out, share your build, help other users
{' '} {t('support.joinDiscordSuffix')}
</li>
</ul>
</section>

View File

@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
@ -19,6 +20,7 @@ import { IconCpu, IconDatabase, IconServer, IconDeviceDesktop, IconComponents }
export default function SettingsPage(props: {
system: { info: SystemInformationResponse | undefined }
}) {
const { t } = useTranslation()
const { data: info } = useSystemInfo({
initialData: props.system.info,
})
@ -44,7 +46,7 @@ export default function SettingsPage(props: {
const handleForceReinstallOllama = () => {
openModal(
<StyledModal
title="Reinstall AI Assistant?"
title={t('system.reinstallAi')}
onConfirm={async () => {
closeAllModals()
setReinstalling(true)
@ -54,14 +56,14 @@ export default function SettingsPage(props: {
throw new Error(response?.message || 'Force reinstall failed')
}
addNotification({
message: 'AI Assistant is being reinstalled with GPU support. This page will reload shortly.',
message: t('system.reinstallSuccess'),
type: 'success',
})
try { localStorage.removeItem('nomad:gpu-banner-dismissed') } catch {}
setTimeout(() => window.location.reload(), 5000)
} catch (error) {
addNotification({
message: `Failed to reinstall: ${error instanceof Error ? error.message : 'Unknown error'}`,
message: t('system.reinstallFailed', { error: error instanceof Error ? error.message : 'Unknown error' }),
type: 'error',
})
setReinstalling(false)
@ -69,13 +71,11 @@ export default function SettingsPage(props: {
}}
onCancel={closeAllModals}
open={true}
confirmText="Reinstall"
confirmText={t('system.reinstallConfirm')}
cancelText="Cancel"
>
<p className="text-text-primary">
This will recreate the AI Assistant container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
{t('system.reinstallAiMessage')}
</p>
</StyledModal>,
'gpu-health-force-reinstall-modal'
@ -110,22 +110,21 @@ export default function SettingsPage(props: {
return (
<SettingsLayout>
<Head title="System Information" />
<Head title={t('system.title')} />
<div className="xl:pl-72 w-full">
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Information</h1>
<h1 className="text-4xl font-bold text-desert-green mb-2">{t('system.heading')}</h1>
<p className="text-desert-stone-dark">
Real-time monitoring and diagnostics Last updated: {new Date().toLocaleString()}
Refreshing data every 30 seconds
{t('system.subtitle', { time: new Date().toLocaleString() })}
</p>
</div>
{Number(memoryUsagePercent) > 90 && (
<div className="mb-6">
<Alert
type="error"
title="Very High Memory Usage Detected"
message="System memory usage exceeds 90%. Performance degradation may occur."
title={t('system.highMemory')}
message={t('system.highMemoryMessage')}
variant="bordered"
/>
</div>
@ -133,24 +132,24 @@ export default function SettingsPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Resource Usage
{t('system.resourceUsage')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={info?.currentLoad.currentLoad || 0}
label="CPU Usage"
label={t('system.cpuUsage')}
size="lg"
variant="cpu"
subtext={`${info?.cpu.cores || 0} cores`}
subtext={t('system.coresCount', { count: info?.cpu.cores || 0 })}
icon={<IconCpu className="w-8 h-8" />}
/>
</div>
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(memoryUsagePercent)}
label="Memory Usage"
label={t('system.memoryUsage')}
size="lg"
variant="memory"
subtext={`${formatBytes(memoryUsed)} / ${formatBytes(info?.mem.total || 0)}`}
@ -160,7 +159,7 @@ export default function SettingsPage(props: {
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(swapUsagePercent)}
label="Swap Usage"
label={t('system.swapUsage')}
size="lg"
variant="disk"
subtext={`${formatBytes(info?.mem.swapused || 0)} / ${formatBytes(info?.mem.swaptotal || 0)}`}
@ -172,34 +171,34 @@ export default function SettingsPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
System Details
{t('system.systemDetails')}
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<InfoCard
title="Operating System"
title={t('system.operatingSystem')}
icon={<IconDeviceDesktop className="w-6 h-6" />}
variant="elevated"
data={[
{ label: 'Distribution', value: info?.os.distro },
{ label: 'Kernel Version', value: info?.os.kernel },
{ label: 'Architecture', value: info?.os.arch },
{ label: 'Hostname', value: info?.os.hostname },
{ label: 'Platform', value: info?.os.platform },
{ label: t('system.distribution'), value: info?.os.distro },
{ label: t('system.kernelVersion'), value: info?.os.kernel },
{ label: t('system.architecture'), value: info?.os.arch },
{ label: t('system.hostname'), value: info?.os.hostname },
{ label: t('system.platform'), value: info?.os.platform },
]}
/>
<InfoCard
title="Processor"
title={t('system.processor')}
icon={<IconCpu className="w-6 h-6" />}
variant="elevated"
data={[
{ label: 'Manufacturer', value: info?.cpu.manufacturer },
{ label: 'Brand', value: info?.cpu.brand },
{ label: 'Cores', value: info?.cpu.cores },
{ label: 'Physical Cores', value: info?.cpu.physicalCores },
{ label: t('system.manufacturer'), value: info?.cpu.manufacturer },
{ label: t('system.brand'), value: info?.cpu.brand },
{ label: t('system.cores'), value: info?.cpu.cores },
{ label: t('system.physicalCores'), value: info?.cpu.physicalCores },
{
label: 'Virtualization',
value: info?.cpu.virtualization ? 'Enabled' : 'Disabled',
label: t('system.virtualization'),
value: info?.cpu.virtualization ? t('system.enabled') : t('system.disabled'),
},
]}
/>
@ -208,12 +207,12 @@ export default function SettingsPage(props: {
<Alert
type="warning"
variant="bordered"
title="GPU Not Accessible to AI Assistant"
message="Your system has an NVIDIA GPU, but the AI Assistant can't access it. AI is running on CPU only, which is significantly slower."
title={t('system.gpuNotAccessible')}
message={t('system.gpuNotAccessibleMessage')}
dismissible={true}
onDismiss={handleDismissGpuBanner}
buttonProps={{
children: 'Fix: Reinstall AI Assistant',
children: t('system.fixReinstallAi'),
icon: 'IconRefresh',
variant: 'action',
size: 'sm',
@ -226,7 +225,7 @@ export default function SettingsPage(props: {
)}
{info?.graphics?.controllers && info.graphics.controllers.length > 0 && (
<InfoCard
title="Graphics"
title={t('system.graphics')}
icon={<IconComponents className="w-6 h-6" />}
variant="elevated"
data={info.graphics.controllers.map((gpu, i) => {
@ -234,7 +233,7 @@ export default function SettingsPage(props: {
return [
{ label: `${prefix}Model`, value: gpu.model },
{ label: `${prefix}Vendor`, value: gpu.vendor },
{ label: `${prefix}VRAM`, value: gpu.vram ? `${gpu.vram} MB` : 'N/A' },
{ label: `${prefix}VRAM`, value: gpu.vram ? `${gpu.vram} MB` : t('system.na') },
]
}).flat()}
/>
@ -244,7 +243,7 @@ export default function SettingsPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Memory Allocation
{t('system.memoryAllocation')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
@ -253,7 +252,7 @@ export default function SettingsPage(props: {
{formatBytes(info?.mem.total || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
Total RAM
{t('system.totalRam')}
</div>
</div>
<div className="text-center">
@ -261,7 +260,7 @@ export default function SettingsPage(props: {
{formatBytes(memoryUsed)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
Used RAM
{t('system.usedRam')}
</div>
</div>
<div className="text-center">
@ -269,7 +268,7 @@ export default function SettingsPage(props: {
{formatBytes(info?.mem.available || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
Available RAM
{t('system.availableRam')}
</div>
</div>
</div>
@ -280,7 +279,7 @@ export default function SettingsPage(props: {
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-white drop-shadow-md z-10">
{memoryUsagePercent}% Utilized
{t('system.utilized', { percent: memoryUsagePercent })}
</span>
</div>
</div>
@ -289,7 +288,7 @@ export default function SettingsPage(props: {
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Storage Devices
{t('system.storageDevices')}
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
@ -299,17 +298,17 @@ export default function SettingsPage(props: {
progressiveBarColor={true}
statuses={[
{
label: 'Normal',
label: t('system.normal'),
min_threshold: 0,
color_class: 'bg-desert-olive',
},
{
label: 'Warning - Usage High',
label: t('system.warningUsageHigh'),
min_threshold: 75,
color_class: 'bg-desert-orange',
},
{
label: 'Critical - Disk Almost Full',
label: t('system.criticalDiskFull'),
min_threshold: 90,
color_class: 'bg-desert-red',
},
@ -317,7 +316,7 @@ export default function SettingsPage(props: {
/>
) : (
<div className="text-center text-desert-stone-dark py-8">
No storage devices detected
{t('system.noStorageDevices')}
</div>
)}
</div>
@ -325,12 +324,12 @@ export default function SettingsPage(props: {
<section>
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
System Status
{t('system.systemStatus')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatusCard title="System Uptime" value={uptimeDisplay} />
<StatusCard title="CPU Cores" value={info?.cpu.cores || 0} />
<StatusCard title="Storage Devices" value={storageItems.length} />
<StatusCard title={t('system.systemUptime')} value={uptimeDisplay} />
<StatusCard title={t('system.cpuCores')} value={info?.cpu.cores || 0} />
<StatusCard title={t('system.storageDevices')} value={storageItems.length} />
</div>
</section>
</main>

View File

@ -1,4 +1,5 @@
import { Head } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import SettingsLayout from '~/layouts/SettingsLayout'
import StyledButton from '~/components/StyledButton'
import StyledTable from '~/components/StyledTable'
@ -24,6 +25,7 @@ type Props = {
}
function ContentUpdatesSection() {
const { t } = useTranslation()
const { addNotification } = useNotifications()
const [checkResult, setCheckResult] = useState<ContentUpdateCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(false)
@ -41,7 +43,7 @@ function ContentUpdatesSection() {
setCheckResult({
updates: [],
checked_at: new Date().toISOString(),
error: 'Failed to check for content updates',
error: t('update.failedToCheckUpdates'),
})
} finally {
setIsChecking(false)
@ -53,7 +55,7 @@ function ContentUpdatesSection() {
try {
const result = await api.applyContentUpdate(update)
if (result?.success) {
addNotification({ type: 'success', message: `Update started for ${update.resource_id}` })
addNotification({ type: 'success', message: t('update.updateStarted', { id: update.resource_id }) })
// Remove from the updates list
setCheckResult((prev) =>
prev
@ -61,10 +63,10 @@ function ContentUpdatesSection() {
: prev
)
} else {
addNotification({ type: 'error', message: result?.error || 'Failed to start update' })
addNotification({ type: 'error', message: result?.error || t('update.failedToStartUpdate') })
}
} catch {
addNotification({ type: 'error', message: `Failed to start update for ${update.resource_id}` })
addNotification({ type: 'error', message: t('update.failedToStartUpdateFor', { id: update.resource_id }) })
} finally {
setApplyingIds((prev) => {
const next = new Set(prev)
@ -83,10 +85,10 @@ function ContentUpdatesSection() {
const succeeded = result.results.filter((r) => r.success).length
const failed = result.results.filter((r) => !r.success).length
if (succeeded > 0) {
addNotification({ type: 'success', message: `Started ${succeeded} update(s)` })
addNotification({ type: 'success', message: t('update.startedUpdates', { count: succeeded }) })
}
if (failed > 0) {
addNotification({ type: 'error', message: `${failed} update(s) could not be started` })
addNotification({ type: 'error', message: t('update.failedUpdates', { count: failed }) })
}
// Remove successful updates from the list
const successIds = new Set(result.results.filter((r) => r.success).map((r) => r.resource_id))
@ -97,7 +99,7 @@ function ContentUpdatesSection() {
)
}
} catch {
addNotification({ type: 'error', message: 'Failed to apply updates' })
addNotification({ type: 'error', message: t('update.failedToApplyUpdates') })
} finally {
setIsApplyingAll(false)
}
@ -105,12 +107,12 @@ function ContentUpdatesSection() {
return (
<div className="mt-8">
<StyledSectionHeader title="Content Updates" />
<StyledSectionHeader title={t('update.contentUpdates')} />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
{t('update.contentUpdatesDescription')}
</p>
<StyledButton
variant="primary"
@ -118,14 +120,14 @@ function ContentUpdatesSection() {
onClick={handleCheck}
loading={isChecking}
>
Check for Content Updates
{t('update.checkForContentUpdates')}
</StyledButton>
</div>
{checkResult?.error && (
<Alert
type="warning"
title="Update Check Issue"
title={t('update.updateCheckIssue')}
message={checkResult.error}
variant="bordered"
className="my-4"
@ -135,8 +137,8 @@ function ContentUpdatesSection() {
{checkResult && !checkResult.error && checkResult.updates.length === 0 && (
<Alert
type="success"
title="All Content Up to Date"
message="All your installed content is running the latest available version."
title={t('update.allContentUpToDate')}
message={t('update.allContentUpToDateMessage')}
variant="bordered"
className="my-4"
/>
@ -146,7 +148,7 @@ function ContentUpdatesSection() {
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-desert-stone-dark">
{checkResult.updates.length} update(s) available
{t('update.updatesAvailable', { count: checkResult.updates.length })}
</p>
<StyledButton
variant="primary"
@ -155,7 +157,7 @@ function ContentUpdatesSection() {
onClick={handleApplyAll}
loading={isApplyingAll}
>
Update All ({checkResult.updates.length})
{t('update.updateAll', { count: checkResult.updates.length })}
</StyledButton>
</div>
<StyledTable
@ -163,14 +165,14 @@ function ContentUpdatesSection() {
columns={[
{
accessor: 'resource_id',
title: 'Title',
title: t('update.columns.title'),
render: (record) => (
<span className="font-medium text-desert-green">{record.resource_id}</span>
),
},
{
accessor: 'resource_type',
title: 'Type',
title: t('update.columns.type'),
render: (record) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${record.resource_type === 'zim'
@ -178,13 +180,13 @@ function ContentUpdatesSection() {
: 'bg-emerald-100 text-emerald-800'
}`}
>
{record.resource_type === 'zim' ? 'ZIM' : 'Map'}
{record.resource_type === 'zim' ? t('update.zim') : t('update.map')}
</span>
),
},
{
accessor: 'installed_version',
title: 'Version',
title: t('update.columns.version'),
render: (record) => (
<span className="text-desert-stone-dark">
{record.installed_version} {record.latest_version}
@ -202,7 +204,7 @@ function ContentUpdatesSection() {
onClick={() => handleApply(record)}
loading={applyingIds.has(record.resource_id)}
>
Update
{t('update.updateButton')}
</StyledButton>
),
},
@ -213,7 +215,7 @@ function ContentUpdatesSection() {
{checkResult?.checked_at && (
<p className="text-xs text-desert-stone mt-3">
Last checked: {new Date(checkResult.checked_at).toLocaleString()}
{t('update.lastChecked')} {new Date(checkResult.checked_at).toLocaleString()}
</p>
)}
</div>
@ -224,6 +226,7 @@ function ContentUpdatesSection() {
}
export default function SystemUpdatePage(props: { system: Props }) {
const { t } = useTranslation()
const { addNotification } = useNotifications()
const [isUpdating, setIsUpdating] = useState(false)
@ -324,16 +327,16 @@ export default function SystemUpdatePage(props: { system: Props }) {
if (data.updateAvailable) {
addNotification({
type: 'success',
message: `Update available: ${data.latestVersion}`,
message: t('update.updateAvailableNotification', { version: data.latestVersion }),
})
} else {
addNotification({ type: 'success', message: 'System is up to date' })
addNotification({ type: 'success', message: t('update.systemUpToDateNotification') })
}
setError(null)
}
},
onError: (error: any) => {
const errorMessage = error?.message || 'Failed to check for updates'
const errorMessage = error?.message || t('update.failedToCheckForUpdates')
setError(errorMessage)
addNotification({ type: 'error', message: errorMessage })
},
@ -361,12 +364,12 @@ export default function SystemUpdatePage(props: { system: Props }) {
return await api.updateSetting(key, value)
},
onSuccess: () => {
addNotification({ message: 'Setting updated successfully.', type: 'success' })
addNotification({ message: t('update.settingUpdated'), type: 'success' })
earlyAccessSetting.refetch()
},
onError: (error) => {
console.error('Error updating setting:', error)
addNotification({ message: 'There was an error updating the setting. Please try again.', type: 'error' })
addNotification({ message: t('update.settingUpdateError'), type: 'error' })
},
})
@ -375,33 +378,32 @@ export default function SystemUpdatePage(props: { system: Props }) {
mutationFn: (email: string) => api.subscribeToReleaseNotes(email),
onSuccess: (data) => {
if (data && data.success) {
addNotification({ type: 'success', message: 'Successfully subscribed to release notes!' })
addNotification({ type: 'success', message: t('update.subscribeSuccess') })
setEmail('')
} else {
addNotification({
type: 'error',
message: `Failed to subscribe: ${data?.message || 'Unknown error'}`,
message: t('update.subscribeFailed', { error: data?.message || 'Unknown error' }),
})
}
},
onError: (error: any) => {
addNotification({
type: 'error',
message: `Error subscribing to release notes: ${error.message || 'Unknown error'}`,
message: t('update.subscribeError', { error: error.message || 'Unknown error' }),
})
},
})
return (
<SettingsLayout>
<Head title="System Update" />
<Head title={t('update.title')} />
<div className="xl:pl-72 w-full">
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Update</h1>
<h1 className="text-4xl font-bold text-desert-green mb-2">{t('update.heading')}</h1>
<p className="text-desert-stone-dark">
Keep your Project N.O.M.A.D. instance up to date with the latest features and
improvements.
{t('update.description')}
</p>
</div>
@ -409,7 +411,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="mb-6">
<Alert
type="error"
title="Update Failed"
title={t('update.updateFailed')}
message={error}
variant="bordered"
dismissible
@ -421,8 +423,8 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="mb-6">
<Alert
type="info"
title="Container Restarting"
message="The admin container is restarting. This page will reload automatically when the update is complete."
title={t('update.containerRestarting')}
message={t('update.containerRestartingMessage')}
variant="solid"
/>
</div>
@ -431,8 +433,8 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="mb-6">
<Alert
type="info"
title="Connection Temporarily Lost (Expected)"
message="You may see error notifications while the backend restarts during the update. This is completely normal and expected. Connection should be restored momentarily."
title={t('update.connectionLost')}
message={t('update.connectionLostMessage')}
variant="solid"
/>
</div>
@ -444,12 +446,12 @@ export default function SystemUpdatePage(props: { system: Props }) {
{!isUpdating && (
<>
<h2 className="text-2xl font-bold text-desert-green mb-2">
{props.system.updateAvailable ? 'Update Available' : 'System Up to Date'}
{props.system.updateAvailable ? t('update.updateAvailable') : t('update.systemUpToDate')}
</h2>
<p className="text-desert-stone-dark mb-6">
{props.system.updateAvailable
? `A new version (${props.system.latestVersion}) is available for your Project N.O.M.A.D. instance.`
: 'Your system is running the latest version!'}
? t('update.newVersionAvailable', { version: props.system.latestVersion })
: t('update.runningLatest')}
</p>
</>
)}
@ -457,7 +459,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
{isUpdating && updateStatus && (
<>
<h2 className="text-2xl font-bold text-desert-green mb-2 capitalize">
{updateStatus.stage === 'idle' ? 'Preparing Update' : updateStatus.stage}
{updateStatus.stage === 'idle' ? t('update.preparingUpdate') : updateStatus.stage}
</h2>
<p className="text-desert-stone-dark mb-6">{updateStatus.message}</p>
</>
@ -465,7 +467,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="flex justify-center gap-8 mb-6">
<div className="text-center">
<p className="text-sm text-desert-stone mb-1">Current Version</p>
<p className="text-sm text-desert-stone mb-1">{t('update.currentVersion')}</p>
<p className="text-xl font-bold text-desert-green">
{versionInfo.currentVersion}
</p>
@ -488,7 +490,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
</svg>
</div>
<div className="text-center">
<p className="text-sm text-desert-stone mb-1">Latest Version</p>
<p className="text-sm text-desert-stone mb-1">{t('update.latestVersion')}</p>
<p className="text-xl font-bold text-desert-olive">
{versionInfo.latestVersion}
</p>
@ -505,7 +507,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/>
</div>
<p className="text-sm text-desert-stone mt-2">
{updateStatus.progress}% complete
{t('update.percentComplete', { percent: updateStatus.progress })}
</p>
</div>
)}
@ -518,7 +520,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
onClick={handleStartUpdate}
disabled={!versionInfo.updateAvailable}
>
{versionInfo.updateAvailable ? 'Start Update' : 'No Update Available'}
{versionInfo.updateAvailable ? t('update.startUpdate') : t('update.noUpdateAvailable')}
</StyledButton>
<StyledButton
variant="ghost"
@ -527,14 +529,14 @@ export default function SystemUpdatePage(props: { system: Props }) {
onClick={() => checkVersionMutation.mutate()}
loading={checkVersionMutation.isPending}
>
Check Again
{t('update.checkAgain')}
</StyledButton>
</div>
)}
</div>
<div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
What happens during an update?
{t('update.whatHappens')}
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
@ -542,9 +544,9 @@ export default function SystemUpdatePage(props: { system: Props }) {
1
</div>
<div>
<p className="font-medium text-desert-stone-dark">Pull Latest Images</p>
<p className="font-medium text-desert-stone-dark">{t('update.step1Title')}</p>
<p className="text-sm text-desert-stone">
Downloads the newest Docker images for all core containers
{t('update.step1Description')}
</p>
</div>
</div>
@ -553,9 +555,9 @@ export default function SystemUpdatePage(props: { system: Props }) {
2
</div>
<div>
<p className="font-medium text-desert-stone-dark">Recreate Containers</p>
<p className="font-medium text-desert-stone-dark">{t('update.step2Title')}</p>
<p className="text-sm text-desert-stone">
Safely stops and recreates all core containers with the new images
{t('update.step2Description')}
</p>
</div>
</div>
@ -564,9 +566,9 @@ export default function SystemUpdatePage(props: { system: Props }) {
3
</div>
<div>
<p className="font-medium text-desert-stone-dark">Automatic Reload</p>
<p className="font-medium text-desert-stone-dark">{t('update.step3Title')}</p>
<p className="text-sm text-desert-stone">
This page will automatically reload when the update is complete
{t('update.step3Description')}
</p>
</div>
</div>
@ -581,7 +583,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
onClick={handleViewLogs}
fullWidth
>
View Update Logs
{t('update.viewUpdateLogs')}
</StyledButton>
</div>
)}
@ -590,18 +592,18 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<Alert
type="info"
title="Backup Reminder"
message="While updates are designed to be safe, it's always recommended to backup any critical data before proceeding."
title={t('update.backupReminder')}
message={t('update.backupReminderMessage')}
variant="solid"
/>
<Alert
type="warning"
title="Temporary Downtime"
message="Services will be briefly unavailable during the update process. This typically takes 2-5 minutes depending on your internet connection."
title={t('update.temporaryDowntime')}
message={t('update.temporaryDowntimeMessage')}
variant="solid"
/>
</div>
<StyledSectionHeader title="Early Access" className="mt-8" />
<StyledSectionHeader title={t('update.earlyAccess')} className="mt-8" />
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<Switch
checked={earlyAccessSetting.data?.value || false}
@ -609,8 +611,8 @@ export default function SystemUpdatePage(props: { system: Props }) {
updateSettingMutation.mutate({ key: 'system.earlyAccess', value: newVal })
}}
disabled={updateSettingMutation.isPending}
label="Enable Early Access"
description="Receive release candidate (RC) versions before they are officially released. Note: RC versions may contain bugs and are not recommended for environments where stability and data integrity are critical."
label={t('update.enableEarlyAccess')}
description={t('update.enableEarlyAccessDescription')}
/>
</div>
<ContentUpdatesSection />
@ -618,8 +620,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
<div>
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
Want to stay updated with the latest from Project N.O.M.A.D.? Subscribe to receive
release notes directly to your inbox. Unsubscribe anytime.
{t('update.subscribeHeading')}
</h2>
</div>
<div className="flex flex-col">
@ -628,7 +629,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
name="email"
label=""
type="email"
placeholder="Your email address"
placeholder={t('update.emailPlaceholder')}
disabled={false}
value={email}
onChange={(e) => setEmail(e.target.value)}
@ -641,12 +642,11 @@ export default function SystemUpdatePage(props: { system: Props }) {
onClick={() => subscribeToReleaseNotesMutation.mutateAsync(email)}
loading={subscribeToReleaseNotesMutation.isPending}
>
Subscribe
{t('update.subscribe')}
</StyledButton>
</div>
<p className="mt-2 text-sm text-desert-stone-dark">
We care about your privacy. Project N.O.M.A.D. will never share your email with
third parties or send you spam.
{t('update.privacyNote')}
</p>
</div>
</div>
@ -656,7 +656,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-surface-primary rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
<h3 className="text-xl font-bold text-desert-green">{t('update.updateLogs')}</h3>
<button
onClick={() => setShowLogs(false)}
className="text-desert-stone hover:text-desert-green transition-colors"
@ -673,12 +673,12 @@ export default function SystemUpdatePage(props: { system: Props }) {
</div>
<div className="p-6 overflow-auto flex-1">
<pre className="bg-black text-green-400 p-4 rounded text-xs font-mono whitespace-pre-wrap">
{logs || 'No logs available yet...'}
{logs || t('update.noLogsAvailable')}
</pre>
</div>
<div className="p-6 border-t border-desert-stone-light">
<StyledButton variant="secondary" onClick={() => setShowLogs(false)} fullWidth>
Close
{t('update.close')}
</StyledButton>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { Head } from '@inertiajs/react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
@ -12,6 +13,7 @@ import { ZimFileWithMetadata } from '../../../../types/zim'
import { SERVICE_NAMES } from '../../../../constants/service_names'
export default function ZimPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
@ -28,19 +30,19 @@ export default function ZimPage() {
async function confirmDeleteFile(file: ZimFileWithMetadata) {
openModal(
<StyledModal
title="Confirm Delete?"
title={t('contentManager.confirmDelete')}
onConfirm={() => {
deleteFileMutation.mutateAsync(file)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Delete"
cancelText="Cancel"
confirmText={t('contentManager.delete')}
cancelText={t('contentManager.cancel')}
confirmVariant="danger"
>
<p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone.
{t('contentManager.confirmDeleteMessage', { name: file.name })}
</p>
</StyledModal>,
'confirm-delete-file-modal'
@ -56,20 +58,20 @@ export default function ZimPage() {
return (
<SettingsLayout>
<Head title="Content Manager | Project N.O.M.A.D." />
<Head title={t('contentManager.title')} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
<h1 className="text-4xl font-semibold mb-2">{t('contentManager.heading')}</h1>
<p className="text-text-muted">
Manage your stored content files.
{t('contentManager.description')}
</p>
</div>
</div>
{!isInstalled && (
<Alert
title="The Kiwix application is not installed. Please install it to view downloaded ZIM files"
title={t('contentManager.kiwixNotInstalled')}
type="warning"
variant='solid'
className="!mt-6"
@ -83,7 +85,7 @@ export default function ZimPage() {
columns={[
{
accessor: 'title',
title: 'Title',
title: t('contentManager.columns.title'),
render: (record) => (
<span className="font-medium">
{record.title || record.name}
@ -92,7 +94,7 @@ export default function ZimPage() {
},
{
accessor: 'summary',
title: 'Summary',
title: t('contentManager.columns.summary'),
render: (record) => (
<span className="text-text-secondary text-sm line-clamp-2">
{record.summary || '—'}
@ -101,7 +103,7 @@ export default function ZimPage() {
},
{
accessor: 'actions',
title: 'Actions',
title: t('contentManager.columns.actions'),
render: (record) => (
<div className="flex space-x-2">
<StyledButton
@ -111,7 +113,7 @@ export default function ZimPage() {
confirmDeleteFile(record)
}}
>
Delete
{t('contentManager.delete')}
</StyledButton>
</div>
),

View File

@ -5,6 +5,7 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import api from '~/lib/api'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
@ -36,6 +37,7 @@ const CURATED_CATEGORIES_KEY = 'curated-categories'
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
export default function ZimRemoteExplorer() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const tableParentRef = useRef<HTMLDivElement>(null)
@ -148,23 +150,18 @@ export default function ZimRemoteExplorer() {
async function confirmDownload(record: RemoteZimFileEntry) {
openModal(
<StyledModal
title="Confirm Download?"
title={t('contentExplorer.confirmDownload')}
onConfirm={() => {
downloadFile(record)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmText={t('contentExplorer.download')}
cancelText={t('contentExplorer.cancel')}
confirmVariant="primary"
>
<p className="text-text-primary">
Are you sure you want to download{' '}
<strong>{record.title}</strong>? It may take some time for it
to be available depending on the file size and your internet connection. The Kiwix
application will be restarted after the download is complete.
</p>
<p className="text-text-primary" dangerouslySetInnerHTML={{ __html: t('contentExplorer.confirmDownloadMessage', { title: record.title }) }} />
</StyledModal>,
'confirm-download-file-modal'
)
@ -196,7 +193,7 @@ export default function ZimRemoteExplorer() {
await api.downloadCategoryTier(category.slug, tier.slug)
addNotification({
message: `Started downloading "${category.name} - ${tier.name}"`,
message: t('contentExplorer.tierDownloadStarted', { name: `${category.name} - ${tier.name}` }),
type: 'success',
})
invalidateDownloads()
@ -206,7 +203,7 @@ export default function ZimRemoteExplorer() {
} catch (error) {
console.error('Error downloading tier resources:', error)
addNotification({
message: 'An error occurred while starting downloads.',
message: t('contentExplorer.tierDownloadError'),
type: 'error',
})
}
@ -233,8 +230,8 @@ export default function ZimRemoteExplorer() {
addNotification({
message:
selectedWikipedia === 'none'
? 'Wikipedia removed successfully'
: 'Wikipedia download started',
? t('contentExplorer.wikipediaRemoved')
: t('contentExplorer.wikipediaDownloadStarted'),
type: 'success',
})
invalidateDownloads()
@ -242,14 +239,14 @@ export default function ZimRemoteExplorer() {
setSelectedWikipedia(null)
} else {
addNotification({
message: result?.message || 'Failed to change Wikipedia selection',
message: result?.message || t('contentExplorer.wikipediaSelectionFailed'),
type: 'error',
})
}
} catch (error) {
console.error('Error selecting Wikipedia:', error)
addNotification({
message: 'An error occurred while changing Wikipedia selection',
message: t('contentExplorer.wikipediaSelectionError'),
type: 'error',
})
} finally {
@ -261,7 +258,7 @@ export default function ZimRemoteExplorer() {
mutationFn: () => api.refreshManifests(),
onSuccess: () => {
addNotification({
message: 'Successfully refreshed content collections.',
message: t('contentExplorer.refreshSuccess'),
type: 'success',
})
queryClient.invalidateQueries({ queryKey: [CURATED_CATEGORIES_KEY] })
@ -271,18 +268,18 @@ export default function ZimRemoteExplorer() {
return (
<SettingsLayout>
<Head title="Content Explorer | Project N.O.M.A.D." />
<Head title={t('contentExplorer.title')} />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex justify-between items-center">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
<p className="text-text-muted">Browse and download content for offline reading!</p>
<h1 className="text-4xl font-semibold mb-2">{t('contentExplorer.heading')}</h1>
<p className="text-text-muted">{t('contentExplorer.description')}</p>
</div>
</div>
{!isOnline && (
<Alert
title="No internet connection. You may not be able to download files."
title={t('contentExplorer.noInternet')}
message=""
type="warning"
variant="solid"
@ -291,20 +288,20 @@ export default function ZimRemoteExplorer() {
)}
{!isInstalled && (
<Alert
title="The Kiwix application is not installed. Please install it to view downloaded content files."
title={t('contentExplorer.kiwixNotInstalled')}
type="warning"
variant="solid"
className="!mt-6"
/>
)}
<div className="mt-8 mb-6 flex items-center justify-between">
<StyledSectionHeader title="Curated Content" className="!mb-0" />
<StyledSectionHeader title={t('contentExplorer.curatedContent')} className="!mb-0" />
<StyledButton
onClick={() => refreshManifests.mutate()}
disabled={refreshManifests.isPending || !isOnline}
icon="IconRefresh"
>
Force Refresh Collections
{t('contentExplorer.forceRefreshCollections')}
</StyledButton>
</div>
@ -336,8 +333,8 @@ export default function ZimRemoteExplorer() {
<IconBooks className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
<h3 className="text-xl font-semibold text-text-primary">{t('contentExplorer.additionalContent')}</h3>
<p className="text-sm text-text-muted">{t('contentExplorer.additionalContentSubtext')}</p>
</div>
</div>
{categories && categories.length > 0 ? (
@ -363,14 +360,14 @@ export default function ZimRemoteExplorer() {
/>
</>
) : (
<p className="text-text-muted mt-4">No curated content categories available.</p>
<p className="text-text-muted mt-4">{t('contentExplorer.noCuratedCategories')}</p>
)}
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
<StyledSectionHeader title={t('contentExplorer.browseKiwixLibrary')} className="mt-12 mb-4" />
<div className="flex justify-start mt-4">
<Input
name="search"
label=""
placeholder="Search available ZIM files..."
placeholder={t('contentExplorer.searchPlaceholder')}
value={queryUI}
onChange={(e) => {
setQueryUI(e.target.value)
@ -394,30 +391,35 @@ export default function ZimRemoteExplorer() {
columns={[
{
accessor: 'title',
title: t('contentExplorer.columns.title'),
},
{
accessor: 'author',
title: t('contentExplorer.columns.author'),
},
{
accessor: 'summary',
title: t('contentExplorer.columns.summary'),
},
{
accessor: 'updated',
title: t('contentExplorer.columns.updated'),
render(record) {
return new Intl.DateTimeFormat('en-US', {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
}).format(new Date(record.updated))
},
},
{
accessor: 'size_bytes',
title: 'Size',
title: t('contentExplorer.columns.size'),
render(record) {
return formatBytes(record.size_bytes)
},
},
{
accessor: 'actions',
title: t('contentExplorer.columns.actions'),
render(record) {
return (
<div className="flex space-x-2">
@ -427,7 +429,7 @@ export default function ZimRemoteExplorer() {
confirmDownload(record)
}}
>
Download
{t('contentExplorer.download')}
</StyledButton>
</div>
)

View File

@ -46,6 +46,8 @@
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.5.6",
"fuse.js": "^7.1.0",
"i18next": "^25.10.5",
"i18next-browser-languagedetector": "^8.2.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
@ -58,6 +60,7 @@
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"react-i18next": "^16.6.2",
"react-map-gl": "^8.1.0",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2",
@ -1092,6 +1095,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@ -9646,6 +9658,15 @@
],
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -9749,6 +9770,46 @@
"node": ">=18.18.0"
}
},
"node_modules/i18next": {
"version": "25.10.5",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.5.tgz",
"integrity": "sha512-jRnF7eRNsdcnh7AASSgaU3lj/8lJZuHkfsouetnLEDH0xxE1vVi7qhiJ9RhdSPUyzg4ltb7P7aXsFlTk9sxL2w==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@ -13776,6 +13837,33 @@
"react": "^19.2.4"
}
},
"node_modules/react-i18next": {
"version": "16.6.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.2.tgz",
"integrity": "sha512-/S/GPzElTqEi5o2kzd0/O2627hPDmE6OGhJCCwCfUaQ3syyu+kaYH8/PYFtZeWc25NzfxTN/2fD1QjvrTgrFfA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -16234,6 +16322,15 @@
"vite": "^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",

View File

@ -98,6 +98,8 @@
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.5.6",
"fuse.js": "^7.1.0",
"i18next": "^25.10.5",
"i18next-browser-languagedetector": "^8.2.1",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
"mysql2": "^3.14.1",
@ -110,6 +112,7 @@
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"react-i18next": "^16.6.2",
"react-map-gl": "^8.1.0",
"react-markdown": "^10.1.0",
"reflect-metadata": "^0.2.2",

View File

@ -60,4 +60,11 @@ export default await Env.create(new URL('../', import.meta.url), {
|----------------------------------------------------------
*/
NOMAD_API_URL: Env.schema.string.optional(),
/*
|----------------------------------------------------------
| Variables for configuring the upstream GitHub repository URL
|----------------------------------------------------------
*/
NOMAD_REPO_URL: Env.schema.string.optional(),
})

View File

@ -0,0 +1,53 @@
{
"spec_version": "2026-02-11",
"options": [
{
"id": "none",
"name": "Kein Wikipedia",
"description": "Wikipedia-Installation überspringen",
"size_mb": 0,
"url": null,
"version": null
},
{
"id": "top-mini",
"name": "Kurzreferenz",
"description": "Top 100.000 Artikel mit minimalen Bildern. Ideal für schnelles Nachschlagen.",
"size_mb": 122,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_de_top_mini_2026-01.zim",
"version": "2026-01"
},
{
"id": "top-nopic",
"name": "Beliebte Artikel",
"description": "Top-Artikel ohne Bilder. Gute Balance zwischen Inhalt und Größe.",
"size_mb": 977,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_de_top_nopic_2026-01.zim",
"version": "2026-01"
},
{
"id": "all-mini",
"name": "Komplette Wikipedia (Kompakt)",
"description": "Alle 2,8+ Millionen Artikel im komprimierten Format.",
"size_mb": 3687,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_mini_2026-01.zim",
"version": "2026-01"
},
{
"id": "all-nopic",
"name": "Komplette Wikipedia (Ohne Bilder)",
"description": "Alle Artikel ohne Bilder. Umfassende Offline-Referenz.",
"size_mb": 13903,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_nopic_2026-01.zim",
"version": "2026-01"
},
{
"id": "all-maxi",
"name": "Komplette Wikipedia (Vollständig)",
"description": "Das vollständige Erlebnis mit allen Bildern und Medien.",
"size_mb": 49965,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_maxi_2026-01.zim",
"version": "2026-01"
}
]
}

View File

@ -0,0 +1,53 @@
{
"spec_version": "2026-02-11",
"options": [
{
"id": "none",
"name": "No Wikipedia",
"description": "Skip Wikipedia installation",
"size_mb": 0,
"url": null,
"version": null
},
{
"id": "top-mini",
"name": "Quick Reference",
"description": "Top 100,000 articles with minimal images. Great for quick lookups.",
"size_mb": 314,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2026-03.zim",
"version": "2026-03"
},
{
"id": "top-nopic",
"name": "Popular Articles",
"description": "Top articles without images. Good balance of content and size.",
"size_mb": 2120,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2026-03.zim",
"version": "2026-03"
},
{
"id": "all-mini",
"name": "Complete Wikipedia (Compact)",
"description": "All 6+ million articles in condensed format.",
"size_mb": 11830,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2026-03.zim",
"version": "2026-03"
},
{
"id": "all-nopic",
"name": "Complete Wikipedia (No Images)",
"description": "All articles without images. Comprehensive offline reference.",
"size_mb": 48818,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim",
"version": "2025-12"
},
{
"id": "all-maxi",
"name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.",
"size_mb": 118237,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2026-02.zim",
"version": "2026-02"
}
]
}

View File

@ -13,31 +13,31 @@
"id": "top-mini",
"name": "Quick Reference",
"description": "Top 100,000 articles with minimal images. Great for quick lookups.",
"size_mb": 313,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2025-12.zim",
"version": "2025-12"
"size_mb": 314,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_mini_2026-03.zim",
"version": "2026-03"
},
{
"id": "top-nopic",
"name": "Popular Articles",
"description": "Top articles without images. Good balance of content and size.",
"size_mb": 2100,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2025-12.zim",
"version": "2025-12"
"size_mb": 2120,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_top_nopic_2026-03.zim",
"version": "2026-03"
},
{
"id": "all-mini",
"name": "Complete Wikipedia (Compact)",
"description": "All 6+ million articles in condensed format.",
"size_mb": 11400,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2025-12.zim",
"version": "2025-12"
"size_mb": 11830,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_2026-03.zim",
"version": "2026-03"
},
{
"id": "all-nopic",
"name": "Complete Wikipedia (No Images)",
"description": "All articles without images. Comprehensive offline reference.",
"size_mb": 25000,
"size_mb": 48818,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_nopic_2025-12.zim",
"version": "2025-12"
},
@ -45,7 +45,7 @@
"id": "all-maxi",
"name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.",
"size_mb": 115000,
"size_mb": 118237,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2026-02.zim",
"version": "2026-02"
}