feat: implement user guide documentation in multiple languages and add IPC infrastructure for UI components

This commit is contained in:
Ümit Tunç
2026-04-02 17:16:52 +03:00
parent baf1739f6d
commit 0a67fda211
32 changed files with 1594 additions and 9 deletions
+62 -8
View File
@@ -366,14 +366,6 @@ export function registerIpcHandlers(): void {
return { success: false, message: 'Bu servis için reload desteklenmiyor.' }
})
ipcMain.handle('services:get-events', async () => {
return serviceEventLogger.getEvents()
})
ipcMain.handle('services:clear-events', async () => {
serviceEventLogger.clear()
return { success: true }
})
ipcMain.handle('services:start', async (_event, serviceName: string) => {
try {
@@ -1192,6 +1184,68 @@ export function registerIpcHandlers(): void {
}
})
ipcMain.handle('docs:open-user-guide', async (_event, locale: string) => {
try {
const guidePath = path.join(process.cwd(), 'docs', 'user-guide', locale, 'index.md')
if (fs.existsSync(guidePath)) {
await shell.openPath(guidePath)
return { success: true }
} else {
// Fallback to directory or English if specific locale file doesn't exist
const langDir = path.join(process.cwd(), 'docs', 'user-guide', locale)
if (fs.existsSync(langDir)) {
await shell.openPath(langDir)
return { success: true }
}
throw new Error(`Kullanım kılavuzu bulunamadı: ${locale}`)
}
} catch (error: any) {
console.error('Failed to open user guide:', error)
return { success: false, message: error.message }
}
})
ipcMain.handle('docs:list-topics', async (_event, locale: string) => {
try {
const localeDir = path.join(process.cwd(), 'docs', 'user-guide', locale)
if (!fs.existsSync(localeDir)) return []
const files = fs.readdirSync(localeDir).filter(f => f.endsWith('.md'))
const topics = files.map(file => {
const fullPath = path.join(localeDir, file)
const content = fs.readFileSync(fullPath, 'utf8')
const firstLine = content.split('\n')[0]
const title = firstLine.replace(/^#\s*/, '').trim() || file
return { file, title }
})
// Reorder so index.md is first
const indexIdx = topics.findIndex(t => t.file === 'index.md')
if (indexIdx > -1) {
const index = topics.splice(indexIdx, 1)[0]
topics.unshift(index)
}
return topics
} catch (error: any) {
console.error('Failed to list topics:', error)
return []
}
})
ipcMain.handle('docs:read-topic', async (_event, { locale, file }) => {
try {
const filePath = path.join(process.cwd(), 'docs', 'user-guide', locale, file)
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, 'utf8')
}
throw new Error(`Dosya bulunamadı: ${file}`)
} catch (error: any) {
console.error('Failed to read topic:', error)
return ''
}
})
ipcMain.handle('services:clear-events', async () => {
try {
serviceEventLogger.clear()
+10 -1
View File
@@ -80,7 +80,8 @@ import {
Restore as ResetIcon,
Search as SearchIcon,
Tune as TuningIcon,
History as HistoryIcon
History as HistoryIcon,
MenuBook as GuideIcon
} from '@mui/icons-material'
import Swal from 'sweetalert2'
import { useTranslation } from 'react-i18next'
@@ -93,6 +94,7 @@ import ApacheCodeEditor from './components/ApacheCodeEditor'
import PhpExtensionManager from './components/PhpExtensionManager'
import ServerTuning from './components/ServerTuning'
import ServiceEventMonitor from './components/ServiceEventMonitor'
import UserGuide from './components/UserGuide'
declare global {
interface Window {
@@ -1137,6 +1139,12 @@ function App(): JSX.Element {
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton selected={activeTab === 'user_guide'} onClick={() => setActiveTab('user_guide')}>
<ListItemIcon><GuideIcon color={activeTab === 'user_guide' ? 'primary' : 'inherit'} /></ListItemIcon>
<ListItemText primary={t('common.user_guide')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton selected={activeTab === 'settings'} onClick={() => setActiveTab('settings')}>
<ListItemIcon><SettingsIcon color={activeTab === 'settings' ? 'primary' : 'inherit'} /></ListItemIcon>
@@ -1159,6 +1167,7 @@ function App(): JSX.Element {
{activeTab === 'tuning' && <ServerTuning />}
{activeTab === 'db_manager' && <MariaDbDatabaseManager versions={mariaDbVersions} />}
{activeTab === 'event_monitor' && <ServiceEventMonitor />}
{activeTab === 'user_guide' && <UserGuide />}
{activeTab === 'settings' && (
<Box>
+355
View File
@@ -0,0 +1,355 @@
import { useState, useEffect } from 'react'
import {
Box,
Typography,
Paper,
List,
ListItem,
ListItemButton,
ListItemText,
Alert,
AlertTitle,
Grid,
Breadcrumbs,
Link,
CircularProgress
} from '@mui/material'
import {
MenuBook as GuideIcon,
ChevronRight as ChevronRightIcon,
Info as InfoIcon,
Lightbulb as TipIcon,
ReportProblem as WarningIcon,
PriorityHigh as CautionIcon
} from '@mui/icons-material'
import { useTranslation } from 'react-i18next'
import Prism from 'prismjs'
interface Topic {
file: string
title: string
}
const UserGuide = () => {
const { t, i18n } = useTranslation()
const [topics, setTopics] = useState<Topic[]>([])
const [selectedTopic, setSelectedTopic] = useState<string>('index.md')
const [content, setContent] = useState<string>('')
const [loading, setLoading] = useState(false)
useEffect(() => {
const fetchTopics = async () => {
const list = await window.api.invoke('docs:list-topics', i18n.language)
setTopics(list)
if (list.length > 0 && !selectedTopic) {
setSelectedTopic(list[0].file)
}
}
fetchTopics()
}, [i18n.language])
useEffect(() => {
const fetchContent = async () => {
if (!selectedTopic) return
setLoading(true)
const text = await window.api.invoke('docs:read-topic', {
locale: i18n.language,
file: selectedTopic
})
setContent(text)
setLoading(false)
// Re-highlight code snippets after content is set
setTimeout(() => Prism.highlightAll(), 50)
}
fetchContent()
}, [selectedTopic, i18n.language])
const renderMarkdown = (text: string) => {
if (!text) return null
// Simple Markdown Parser (Line by Line)
const lines = text.split('\n')
const elements: JSX.Element[] = []
let currentList: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
let codeLang = ''
let inAlert = false
let alertType: 'info' | 'warning' | 'error' | 'success' = 'info'
let alertTitle = ''
let alertLines: string[] = []
const flushList = () => {
if (currentList.length > 0) {
elements.push(
<Box component="ul" key={`list-${elements.length}`} sx={{ pl: 3, mb: 2 }}>
{currentList}
</Box>
)
currentList = []
}
}
const flushAlert = () => {
if (inAlert) {
const Icon = alertType === 'warning' ? WarningIcon :
alertType === 'error' ? CautionIcon :
alertType === 'info' ? InfoIcon : TipIcon
elements.push(
<Alert
severity={alertType}
key={`alert-${elements.length}`}
sx={{ my: 2, '& .MuiAlert-icon': { alignItems: 'center' } }}
icon={<Icon />}
>
{alertTitle && <AlertTitle sx={{ fontWeight: 'bold' }}>{alertTitle}</AlertTitle>}
{alertLines.map((line, i) => (
<Typography key={i} variant="body2" sx={{ mb: i === alertLines.length - 1 ? 0 : 0.5 }}>
{parseInline(line)}
</Typography>
))}
</Alert>
)
inAlert = false
alertLines = []
alertTitle = ''
}
}
const parseInline = (line: string) => {
// Very simple inline parser for bold and raw code
let parts = line.split(/(\*\*.*?\*\*|`.*?`)/)
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={i}>{part.slice(2, -2)}</strong>
}
if (part.startsWith('`') && part.endsWith('`')) {
return <code key={i} style={{ backgroundColor: 'rgba(255,255,255,0.05)', padding: '2px 4px', borderRadius: 4 }}>{part.slice(1, -1)}</code>
}
return part
})
}
lines.forEach((line, index) => {
const trimmedLine = line.trim()
// Code blocks
if (trimmedLine.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<Paper
key={`code-${index}`}
variant="outlined"
sx={{
bgcolor: '#0d1117',
p: 2,
mb: 2,
overflow: 'auto',
borderColor: 'rgba(255,255,255,0.1)'
}}
>
<pre style={{ margin: 0 }}>
<code className={`language-${codeLang || 'plaintext'}`}>
{codeContent.trim()}
</code>
</pre>
</Paper>
)
inCodeBlock = false
codeContent = ''
codeLang = ''
} else {
flushList()
flushAlert()
inCodeBlock = true
codeLang = trimmedLine.slice(3).trim()
}
return
}
if (inCodeBlock) {
codeContent += line + '\n'
return
}
// Alerts [!TIP], etc.
const alertMatch = trimmedLine.match(/^>\s*\[!(TIP|WARNING|IMPORTANT|NOTE|CAUTION)\]/)
if (alertMatch) {
flushList()
flushAlert()
inAlert = true
const type = alertMatch[1]
alertType = type === 'WARNING' || type === 'CAUTION' ? 'warning' :
type === 'IMPORTANT' ? 'error' :
type === 'TIP' ? 'success' : 'info'
alertTitle = type
return
}
if (inAlert) {
if (trimmedLine.startsWith('>') || trimmedLine === '') {
const content = trimmedLine.replace(/^>\s*/, '')
if (content) alertLines.push(content)
return
} else {
flushAlert()
}
}
// Headers
if (trimmedLine.startsWith('#')) {
flushList()
flushAlert()
const level = trimmedLine.match(/^#+/)?.[0].length || 1
const content = trimmedLine.replace(/^#+\s*/, '')
const variant = level === 1 ? 'h4' : level === 2 ? 'h5' : 'h6'
elements.push(
<Typography
key={index}
variant={variant}
sx={{
mt: level === 1 ? 0 : 4,
mb: 2,
fontWeight: level === 1 ? 800 : 700,
color: level === 1 ? 'primary.light' : '#fff',
borderBottom: level === 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
pb: level === 1 ? 1 : 0
}}
>
{parseInline(content)}
</Typography>
)
return
}
// Lists
if (trimmedLine.startsWith('- ') || trimmedLine.startsWith('* ')) {
flushAlert()
const content = trimmedLine.slice(2)
currentList.push(
<Box component="li" key={`li-${index}`} sx={{ mb: 1, color: 'rgba(255,255,255,0.9)' }}>
<Typography variant="body2">{parseInline(content)}</Typography>
</Box>
)
return
}
// Paragraphs
if (trimmedLine === '') {
flushList()
flushAlert()
return
}
if (!inCodeBlock && !inAlert) {
flushList()
elements.push(
<Typography
key={index}
variant="body1"
sx={{
mb: 2,
lineHeight: 1.7,
color: 'rgba(255,255,255,0.8)',
fontSize: '0.95rem'
}}
>
{parseInline(trimmedLine)}
</Typography>
)
}
})
flushList()
flushAlert()
return elements
}
return (
<Box sx={{ height: 'calc(100vh - 120px)', display: 'flex', flexDirection: 'column' }}>
{/* Breadcrumbs */}
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<Breadcrumbs separator={<ChevronRightIcon sx={{ fontSize: '1rem', color: 'rgba(255,255,255,0.3)' }} />} aria-label="breadcrumb">
<Link underline="hover" color="inherit" sx={{ display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.5)', fontSize: '0.85rem' }}>
<GuideIcon sx={{ mr: 0.5, fontSize: '1.1rem' }} />
{t('common.user_guide')}
</Link>
<Typography color="primary.light" sx={{ fontSize: '0.85rem', fontWeight: 'bold' }}>
{topics.find(t => t.file === selectedTopic)?.title || selectedTopic}
</Typography>
</Breadcrumbs>
</Box>
<Grid container spacing={4} sx={{ flexGrow: 1, overflow: 'hidden' }}>
{/* Topics Sidebar */}
<Grid item xs={12} md={3} sx={{ height: '100%', overflowY: 'auto' }}>
<Paper
variant="outlined"
sx={{
bgcolor: 'rgba(255,255,255,0.02)',
borderColor: 'rgba(255,255,255,0.05)',
borderRadius: 3,
overflow: 'hidden'
}}
>
<Typography variant="overline" sx={{ px: 2, pt: 2, display: 'block', color: 'primary.main', fontWeight: 800 }}>
{t('common.topics', 'KONULAR')}
</Typography>
<List sx={{ pt: 1 }}>
{topics.map((topic) => (
<ListItem key={topic.file} disablePadding>
<ListItemButton
selected={selectedTopic === topic.file}
onClick={() => setSelectedTopic(topic.file)}
sx={{
py: 1.5,
borderLeft: '3px solid',
borderColor: selectedTopic === topic.file ? 'primary.main' : 'transparent',
bgcolor: selectedTopic === topic.file ? 'rgba(57, 167, 255, 0.05)' : 'transparent',
'&:hover': { bgcolor: 'rgba(255,255,255,0.05)' }
}}
>
<ListItemText
primary={topic.title}
primaryTypographyProps={{
variant: 'body2',
fontWeight: selectedTopic === topic.file ? 700 : 500,
color: selectedTopic === topic.file ? '#fff' : 'rgba(255,255,255,0.6)'
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
</Grid>
{/* Content Viewer */}
<Grid item xs={12} md={9} sx={{ height: '100%', overflowY: 'auto', pr: 2 }}>
<Paper
elevation={0}
sx={{
p: { xs: 3, md: 5 },
bgcolor: 'rgba(0,0,0,0.2)',
borderRadius: 4,
minHeight: '100%',
border: '1px solid rgba(255,255,255,0.03)',
position: 'relative'
}}
>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '200px' }}>
<CircularProgress size={30} />
</Box>
) : (
renderMarkdown(content)
)}
</Paper>
</Grid>
</Grid>
</Box>
)
}
export default UserGuide
+1
View File
@@ -8,6 +8,7 @@
"close": "Schließen",
"quit": "Beenden",
"save": "Speichern",
"user_guide": "Benutzerhandbuch",
"port": "Port",
"all": "Alle",
"export": "Exportieren",
+1
View File
@@ -9,6 +9,7 @@
"close": "Close",
"quit": "Quit",
"save": "Save",
"user_guide": "User Guide",
"port": "Port",
"all": "All",
"export": "Export",
+1
View File
@@ -9,6 +9,7 @@
"close": "Kapat",
"quit": "Çıkış",
"save": "Kaydet",
"user_guide": "Kullanım Kılavuzu",
"port": "Port",
"all": "Tümü",
"export": "Dışa Aktar",