feat: implement user guide documentation in multiple languages and add IPC infrastructure for UI components
This commit is contained in:
+62
-8
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -8,6 +8,7 @@
|
||||
"close": "Schließen",
|
||||
"quit": "Beenden",
|
||||
"save": "Speichern",
|
||||
"user_guide": "Benutzerhandbuch",
|
||||
"port": "Port",
|
||||
"all": "Alle",
|
||||
"export": "Exportieren",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"close": "Close",
|
||||
"quit": "Quit",
|
||||
"save": "Save",
|
||||
"user_guide": "User Guide",
|
||||
"port": "Port",
|
||||
"all": "All",
|
||||
"export": "Export",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user