feat: implement core UI dashboard and backend service management for multi-PHP environment

This commit is contained in:
Ümit Tunç
2026-03-28 08:56:51 +03:00
parent 5372448ead
commit b38e3d98bd
7 changed files with 531 additions and 54 deletions
+42
View File
@@ -2,6 +2,8 @@ import { ipcMain, app } from 'electron'
import { processManager } from '../services/ProcessManager' import { processManager } from '../services/ProcessManager'
import { configService } from '../services/ConfigService' import { configService } from '../services/ConfigService'
import { downloadService } from '../services/DownloadService' import { downloadService } from '../services/DownloadService'
import { projectService } from '../services/ProjectService'
import { phpManagerService } from '../services/PhpManagerService'
import path from 'path' import path from 'path'
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
@@ -54,6 +56,33 @@ export function registerIpcHandlers(): void {
}) })
// Binary management // Binary management
ipcMain.handle('binaries:list-php', async () => {
return phpManagerService.getVersions()
})
ipcMain.handle('binaries:download-php', async (event, id: string) => {
const versions = await phpManagerService.getVersions()
const version = versions.find(v => v.id === id)
if (!version) return { success: false, message: 'Geçersiz sürüm.' }
try {
// Signal immediate start
phpManagerService.updateProgress(id, 0, 'downloading')
event.sender.send('binaries:progress', { id, progress: 0 })
await downloadService.downloadAndExtract(version.url, id, (p) => {
phpManagerService.updateProgress(id, p)
event.sender.send('binaries:progress', { id, progress: p })
})
phpManagerService.updateProgress(id, 100, 'installed')
return { success: true, message: 'İndirme tamamlandı.' }
} catch (error: any) {
phpManagerService.updateProgress(id, 0, 'available')
return { success: false, message: `Hata: ${error.message}` }
}
})
ipcMain.handle('binaries:download', async (_event, { name, url }) => { ipcMain.handle('binaries:download', async (_event, { name, url }) => {
return downloadService.downloadAndExtract(url, name, (p) => { return downloadService.downloadAndExtract(url, name, (p) => {
// TODO: Emit progress to renderer via webContents.send // TODO: Emit progress to renderer via webContents.send
@@ -61,6 +90,19 @@ export function registerIpcHandlers(): void {
}) })
}) })
// Project management
ipcMain.handle('projects:list', async () => {
return projectService.getProjects()
})
ipcMain.handle('projects:add', async (_event, project) => {
return projectService.addProject(project)
})
ipcMain.handle('projects:remove', async (_event, id) => {
return projectService.removeProject(id)
})
// Config management // Config management
ipcMain.handle('config:get', async (_event, key: string) => { ipcMain.handle('config:get', async (_event, key: string) => {
return null return null
+47 -30
View File
@@ -2,13 +2,14 @@ import axios from 'axios'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { app } from 'electron' import { app } from 'electron'
import * as decompress from 'decompress' const decompress = require('decompress')
export class DownloadService { export class DownloadService {
private binDir: string private binDir: string
constructor() { constructor() {
this.binDir = path.join(app.getAppPath(), 'bin') // Use userData for binaries to avoid permission issues in Program Files
this.binDir = path.join(app.getPath('userData'), 'bin')
if (!fs.existsSync(this.binDir)) { if (!fs.existsSync(this.binDir)) {
fs.mkdirSync(this.binDir, { recursive: true }) fs.mkdirSync(this.binDir, { recursive: true })
} }
@@ -17,42 +18,58 @@ export class DownloadService {
async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void): Promise<string> { async downloadAndExtract(url: string, targetName: string, onProgress?: (p: number) => void): Promise<string> {
const tempFile = path.join(app.getPath('temp'), `${targetName}.zip`) const tempFile = path.join(app.getPath('temp'), `${targetName}.zip`)
const targetDir = path.join(this.binDir, targetName) const targetDir = path.join(this.binDir, targetName)
const phpExe = path.join(targetDir, 'php.exe')
if (fs.existsSync(targetDir)) { // If already installed and working, skip
if (fs.existsSync(phpExe)) {
return targetDir return targetDir
} }
// Cleanup failed attempts
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true })
}
const writer = fs.createWriteStream(tempFile) const writer = fs.createWriteStream(tempFile)
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
const totalLength = response.headers['content-length'] try {
let downloadedLength = 0 const response = await axios({
url,
response.data.on('data', (chunk: any) => { method: 'GET',
downloadedLength += chunk.length responseType: 'stream',
if (onProgress && totalLength) { headers: {
onProgress(Math.round((downloadedLength / totalLength) * 100)) 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', async () => {
try {
await decompress(tempFile, targetDir)
fs.unlinkSync(tempFile)
resolve(targetDir)
} catch (err) {
reject(err)
} }
}) })
writer.on('error', reject)
}) const totalLength = parseInt(response.headers['content-length'] || '0')
let downloadedLength = 0
response.data.on('data', (chunk: any) => {
downloadedLength += chunk.length
if (onProgress && totalLength > 0) {
onProgress(Math.round((downloadedLength / totalLength) * 100))
}
})
response.data.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', async () => {
try {
await decompress(tempFile, targetDir)
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile)
resolve(targetDir)
} catch (err) {
reject(err)
}
})
writer.on('error', reject)
})
} catch (error) {
if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile)
throw error
}
} }
getBinPath(service: string, executable: string): string { getBinPath(service: string, executable: string): string {
+114
View File
@@ -0,0 +1,114 @@
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import axios from 'axios'
export interface PhpVersion {
id: string
version: string
url: string
status: 'available' | 'downloading' | 'installed'
progress: number
}
export class PhpManagerService {
private versions: PhpVersion[] = []
private binDir: string
private winPhpUrl = 'https://windows.php.net/downloads/releases/'
private winReleasesJsonUrl = 'https://windows.php.net/downloads/releases/releases.json'
private mainReleasesJsonUrl = 'https://www.php.net/releases/index.php?json'
constructor() {
this.binDir = path.join(app.getPath('userData'), 'bin')
this.init()
}
private async init() {
if (!fs.existsSync(this.binDir)) {
fs.mkdirSync(this.binDir, { recursive: true })
}
await this.fetchAvailableVersions()
}
private async fetchAvailableVersions() {
try {
// Fetch from windows.php.net for actual paths
const winResponse = await axios.get(this.winReleasesJsonUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
})
const winData = winResponse.data
// Fetch from php.net for supported versions list
const mainResponse = await axios.get(this.mainReleasesJsonUrl, {
headers: { 'User-Agent': 'Mozilla/5.0' }
})
const mainData = mainResponse.data
const dynamicVersions: PhpVersion[] = []
// Method 1: Versions explicitly available in windows releases.json
for (const majorMinor in winData) {
const release = winData[majorMinor]
const version = release.version
const tsBuild = release['ts-vs16-x64'] || release['ts-vs17-x64'] || release['ts-vc15-x64']
if (tsBuild && tsBuild.zip) {
dynamicVersions.push({
id: `php-${version}`,
version: version,
url: this.winPhpUrl + tsBuild.zip.path,
status: 'available',
progress: 0
})
}
}
// Method 2: Check supported_versions from main php.net
// This helps if windows releases.json doesn't group some newer versions yet
if (mainData['8'] && mainData['8'].supported_versions) {
for (const ver of mainData['8'].supported_versions) {
// ver is "8.2", "8.3" etc.
// If we already have a version for this branch, we are good.
// Otherwise, we could try to guess or find more.
}
}
// De-duplicate by version string
const uniqueVersionsMap = new Map<string, PhpVersion>()
dynamicVersions.forEach(v => uniqueVersionsMap.set(v.version, v))
this.versions = Array.from(uniqueVersionsMap.values()).sort((a, b) => b.version.localeCompare(a.version))
} catch (e) {
console.error('Failed to fetch PHP versions:', e)
}
}
private refreshInstalledStatus() {
this.versions = this.versions.map(v => {
const targetDir = path.join(this.binDir, v.id)
const phpExe = path.join(targetDir, 'php.exe')
if (fs.existsSync(phpExe)) {
return { ...v, status: 'installed', progress: 100 }
}
return v
})
}
async getVersions(): Promise<PhpVersion[]> {
if (this.versions.length === 0) {
await this.fetchAvailableVersions()
}
this.refreshInstalledStatus()
return this.versions
}
updateProgress(id: string, progress: number, status: PhpVersion['status'] = 'downloading') {
const version = this.versions.find(v => v.id === id)
if (version) {
version.progress = progress
version.status = status
}
}
}
export const phpManagerService = new PhpManagerService()
+58
View File
@@ -0,0 +1,58 @@
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
export interface Project {
id: string
name: string
path: string
phpVersion: string
host: string
}
export class ProjectService {
private projectsFile: string
private projects: Project[] = []
constructor() {
this.projectsFile = path.join(app.getPath('userData'), 'projects.json')
this.loadProjects()
}
private loadProjects() {
if (fs.existsSync(this.projectsFile)) {
try {
const data = fs.readFileSync(this.projectsFile, 'utf-8')
this.projects = JSON.parse(data)
} catch (e) {
console.error('Failed to load projects:', e)
this.projects = []
}
}
}
private saveProjects() {
fs.writeFileSync(this.projectsFile, JSON.stringify(this.projects, null, 2))
}
getProjects(): Project[] {
return this.projects
}
addProject(project: Omit<Project, 'id'>): Project {
const newProject = {
...project,
id: Date.now().toString()
}
this.projects.push(newProject)
this.saveProjects()
return newProject
}
removeProject(id: string) {
this.projects = this.projects.filter(p => p.id !== id)
this.saveProjects()
}
}
export const projectService = new ProjectService()
+7 -1
View File
@@ -7,7 +7,13 @@ const api = {
stopService: (name: string) => electronAPI.ipcRenderer.invoke('services:stop', name), stopService: (name: string) => electronAPI.ipcRenderer.invoke('services:stop', name),
getServiceStatus: () => electronAPI.ipcRenderer.invoke('services:status'), getServiceStatus: () => electronAPI.ipcRenderer.invoke('services:status'),
getConfig: (key: string) => electronAPI.ipcRenderer.invoke('config:get', key), getConfig: (key: string) => electronAPI.ipcRenderer.invoke('config:get', key),
saveConfig: (config: any) => electronAPI.ipcRenderer.invoke('config:save', config) saveConfig: (config: any) => electronAPI.ipcRenderer.invoke('config:save', config),
getProjects: () => electronAPI.ipcRenderer.invoke('projects:list'),
addProject: (project: any) => electronAPI.ipcRenderer.invoke('projects:add', project),
removeProject: (id: string) => electronAPI.ipcRenderer.invoke('projects:remove', id),
getPhpVersions: () => electronAPI.ipcRenderer.invoke('binaries:list-php'),
downloadPhpVersion: (id: string) => electronAPI.ipcRenderer.invoke('binaries:download-php', id),
onBinaryProgress: (callback: any) => electronAPI.ipcRenderer.on('binaries:progress', (_event, data) => callback(data))
} }
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
+208 -14
View File
@@ -19,7 +19,17 @@ import {
Divider, Divider,
Button, Button,
Snackbar, Snackbar,
Alert Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
LinearProgress
} from '@mui/material' } from '@mui/material'
import { import {
Dashboard as DashboardIcon, Dashboard as DashboardIcon,
@@ -31,9 +41,13 @@ import {
Code as PhpIcon, Code as PhpIcon,
PlayArrow as StartIcon, PlayArrow as StartIcon,
Stop as StopIcon, Stop as StopIcon,
Refresh as RestartIcon Delete as DeleteIcon,
Download as DownloadIcon,
CheckCircle as InstalledIcon,
CloudDownload as AvailableIcon
} from '@mui/icons-material' } from '@mui/icons-material'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
declare global { declare global {
interface Window { interface Window {
api: any api: any
@@ -56,21 +70,58 @@ function App(): JSX.Element {
mysql: 'stopped' mysql: 'stopped'
}) })
const [activeTab, setActiveTab] = useState('dashboard') const [activeTab, setActiveTab] = useState('dashboard')
const [projects, setProjects] = useState<any[]>([])
const [phpVersions, setPhpVersions] = useState<any[]>([])
const [isProjectDialogOpen, setIsProjectDialogOpen] = useState(false)
const [newProject, setNewProject] = useState({ name: '', path: '', phpVersion: '8.2', host: 'localhost' })
const [notification, setNotification] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({ const [notification, setNotification] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({
open: false, open: false,
message: '', message: '',
severity: 'success' severity: 'success'
}) })
const fetchStatus = async () => {
// @ts-ignore
const currentStatus = await window.api.getServiceStatus()
if (currentStatus) setStatus(currentStatus)
}
const fetchProjects = async () => {
// @ts-ignore
const list = await window.api.getProjects()
setProjects(list || [])
}
const fetchPhpVersions = async () => {
// @ts-ignore
const list = await window.api.getPhpVersions()
setPhpVersions(list || [])
}
useEffect(() => { useEffect(() => {
const fetchStatus = async () => { if (!window.api) return
// @ts-ignore
const currentStatus = await window.api.getServiceStatus()
if (currentStatus) setStatus(currentStatus)
}
fetchStatus() fetchStatus()
const interval = setInterval(fetchStatus, 3000) fetchProjects()
return () => clearInterval(interval) fetchPhpVersions()
// @ts-ignore
const removeListener = window.api.onBinaryProgress((data) => {
if (!data) return
const { id, progress } = data
setPhpVersions(prev => prev.map(v => v.id === id ? { ...v, progress, status: progress === 100 ? 'installed' : 'downloading' } : v))
})
const interval = setInterval(() => {
fetchStatus()
fetchProjects()
fetchPhpVersions()
}, 3000)
return () => {
clearInterval(interval)
if (typeof removeListener === 'function') removeListener()
}
}, []) }, [])
const handleToggleService = async (service: keyof ServiceStatus) => { const handleToggleService = async (service: keyof ServiceStatus) => {
@@ -118,6 +169,33 @@ function App(): JSX.Element {
return <Chip label={s.toUpperCase()} color={color} size="small" sx={{ fontWeight: 'bold' }} /> return <Chip label={s.toUpperCase()} color={color} size="small" sx={{ fontWeight: 'bold' }} />
} }
const handleAddProject = async () => {
// @ts-ignore
await window.api.addProject(newProject)
setIsProjectDialogOpen(false)
setNewProject({ name: '', path: '', phpVersion: '8.2', host: 'localhost' })
setNotification({ open: true, message: 'Proje başarıyla eklendi.', severity: 'success' })
fetchProjects()
}
const handleRemoveProject = async (id: string) => {
// @ts-ignore
await window.api.removeProject(id)
setNotification({ open: true, message: 'Proje silindi.', severity: 'success' })
fetchProjects()
}
const handleDownloadPhp = async (id: string) => {
// @ts-ignore
const result = await window.api.downloadPhpVersion(id)
setNotification({
open: true,
message: result.message,
severity: result.success ? 'success' : 'error'
})
fetchPhpVersions()
}
return ( return (
<Box sx={{ display: 'flex', minHeight: '100vh', backgroundImage: 'radial-gradient(circle at 50% 0%, #153E5E 0%, #121212 100%)', bgcolor: 'transparent' }}> <Box sx={{ display: 'flex', minHeight: '100vh', backgroundImage: 'radial-gradient(circle at 50% 0%, #153E5E 0%, #121212 100%)', bgcolor: 'transparent' }}>
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, bgcolor: 'rgba(21, 62, 94, 0.85)', backdropFilter: 'blur(10px)' }}> <AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1, bgcolor: 'rgba(21, 62, 94, 0.85)', backdropFilter: 'blur(10px)' }}>
@@ -175,6 +253,49 @@ function App(): JSX.Element {
<Box component="main" sx={{ flexGrow: 1, p: 3 }}> <Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar /> <Toolbar />
<Container maxWidth="md"> <Container maxWidth="md">
{activeTab === 'settings' && (
<Box>
<Typography variant="h5" sx={{ fontWeight: 'medium', mb: 3 }}>
Genel Ayarlar
</Typography>
<Paper sx={{ mb: 4, bgcolor: 'rgba(255, 255, 255, 0.03)', borderRadius: 2 }}>
<Box sx={{ p: 2, borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>PHP Sürümleri</Typography>
<Typography variant="body2" color="text.secondary">Gereken PHP sürümlerini buradan indirebilir ve yönetebilirsiniz.</Typography>
</Box>
<List>
{phpVersions.map((v) => (
<ListItem key={v.id} sx={{ py: 2 }}>
<ListItemIcon>
{v.status === 'installed' ? <InstalledIcon color="success" /> : <AvailableIcon color="action" />}
</ListItemIcon>
<ListItemText
primary={`PHP ${v.version}`}
secondary={v.status === 'downloading' ? `İndiriliyor... %${Math.round(v.progress)}` : v.status === 'installed' ? 'Kurulu' : 'İndirilebilir'}
sx={{ flexGrow: 1 }}
/>
<Box sx={{ minWidth: 120, textAlign: 'right' }}>
{v.status === 'available' && (
<Button variant="outlined" size="small" startIcon={<DownloadIcon />} onClick={() => handleDownloadPhp(v.id)}>
İNDİR
</Button>
)}
{v.status === 'downloading' && (
<Box sx={{ width: '100%', mt: 1 }}>
<LinearProgress variant="determinate" value={v.progress} sx={{ height: 8, borderRadius: 5 }} />
</Box>
)}
{v.status === 'installed' && (
<Chip label="HAZIR" color="success" variant="outlined" size="small" />
)}
</Box>
</ListItem>
))}
</List>
</Paper>
</Box>
)}
{activeTab === 'dashboard' && ( {activeTab === 'dashboard' && (
<> <>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 'medium' }}> <Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 'medium' }}>
@@ -223,11 +344,41 @@ function App(): JSX.Element {
)} )}
{activeTab === 'projects' && ( {activeTab === 'projects' && (
<Box sx={{ textAlign: 'center', mt: 4 }}> <Box>
<Typography variant="h6">Henüz bir proje eklenmemiş.</Typography> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Button variant="contained" startIcon={<ProjectIcon />} sx={{ mt: 2 }}> <Typography variant="h5" sx={{ fontWeight: 'medium' }}>
Yeni Proje Ekle Projelerim
</Button> </Typography>
<Button variant="contained" startIcon={<ProjectIcon />} onClick={() => setIsProjectDialogOpen(true)}>
Yeni Proje Ekle
</Button>
</Box>
{projects.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center', bgcolor: 'rgba(255, 255, 255, 0.03)' }}>
<Typography variant="body1" color="text.secondary">Henüz bir proje eklenmemiş.</Typography>
</Paper>
) : (
<List>
{projects.map((project) => (
<Paper key={project.id} sx={{ mb: 2, bgcolor: 'rgba(255, 255, 255, 0.05)' }}>
<ListItem
secondaryAction={
<IconButton edge="end" aria-label="delete" onClick={() => handleRemoveProject(project.id)}>
<DeleteIcon color="error" />
</IconButton>
}
>
<ListItemIcon><ProjectIcon color="primary" /></ListItemIcon>
<ListItemText
primary={project.name}
secondary={`${project.path} - PHP: ${project.phpVersion} - Host: ${project.host}`}
/>
</ListItem>
</Paper>
))}
</List>
)}
</Box> </Box>
)} )}
</Container> </Container>
@@ -242,6 +393,49 @@ function App(): JSX.Element {
{notification.message} {notification.message}
</Alert> </Alert>
</Snackbar> </Snackbar>
<Dialog open={isProjectDialogOpen} onClose={() => setIsProjectDialogOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Yeni Proje Ekle</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Proje Adı"
fullWidth
value={newProject.name}
onChange={(e) => setNewProject({ ...newProject, name: e.target.value })}
/>
<TextField
label="Dosya Yolu (Absolute path)"
fullWidth
value={newProject.path}
onChange={(e) => setNewProject({ ...newProject, path: e.target.value })}
/>
<TextField
label="Hostname (örn: local.test)"
fullWidth
value={newProject.host}
onChange={(e) => setNewProject({ ...newProject, host: e.target.value })}
/>
<FormControl fullWidth>
<InputLabel>PHP Sürümü</InputLabel>
<Select
value={newProject.phpVersion}
label="PHP Sürümü"
onChange={(e) => setNewProject({ ...newProject, phpVersion: e.target.value })}
>
<MenuItem value="8.0">PHP 8.0</MenuItem>
<MenuItem value="8.1">PHP 8.1</MenuItem>
<MenuItem value="8.2">PHP 8.2</MenuItem>
<MenuItem value="8.3">PHP 8.3</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsProjectDialogOpen(false)}>İptal</Button>
<Button onClick={handleAddProject} variant="contained" disabled={!newProject.name || !newProject.path}>Ekle</Button>
</DialogActions>
</Dialog>
</Box> </Box>
) )
} }
+55 -9
View File
@@ -1,27 +1,73 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './i18n' import i18n from './i18n'
import { ThemeProvider, createTheme } from '@mui/material/styles' import { ThemeProvider, createTheme } from '@mui/material/styles'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import { I18nextProvider } from 'react-i18next'
const theme = createTheme({ const theme = createTheme({
palette: { palette: {
mode: 'dark',
primary: { primary: {
main: '#153E5E',
},
secondary: {
main: '#4A7CA3', main: '#4A7CA3',
}, },
mode: 'dark', secondary: {
main: '#153E5E',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
text: {
primary: '#ffffff',
secondary: 'rgba(255, 255, 255, 0.7)',
},
},
components: {
MuiPaper: {
styleOverrides: {
root: {
backgroundImage: 'none',
},
},
},
}, },
}) })
// Error Boundary for better debugging
class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean, error: any }> {
constructor(props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error) {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 20, color: 'white' }}>
<h1>Görüntüleme Hatası Oluştu</h1>
<pre style={{ whiteSpace: 'pre-wrap' }}>{this.state.error?.toString()}</pre>
</div>
)
}
return this.props.children
}
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}> <ErrorBoundary>
<CssBaseline /> <I18nextProvider i18n={i18n}>
<App /> <ThemeProvider theme={theme}>
</ThemeProvider> <CssBaseline />
<App />
</ThemeProvider>
</I18nextProvider>
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
) )