feat(ui.client): add plans page with markdown rendering
- Add PlansPage component with DOMPurify-sanitized markdown rendering
- Add AbortController to prevent race conditions on fast switching
- Memoize marked.parse output
- Add key={env} to PlansPage for env-consistent remount
- Encode filename in API request to handle special chars
- Fix backend: fileURLToPath instead of import.meta.dirname
- Fix backend: try/catch on readdir, remove redundant stat calls
- Fix backend: block backslash path traversal
This commit is contained in:
@@ -1,19 +1,32 @@
|
|||||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||||
import { resolve, extname } from 'node:path'
|
import { resolve, extname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
|
||||||
const PLANS_DIR = resolve(import.meta.dirname || __dirname, '../../plans')
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = resolve(__filename, '..')
|
||||||
|
const PLANS_DIR = resolve(__dirname, '../../plans')
|
||||||
|
|
||||||
export async function plansRoutes(app: FastifyInstance): Promise<void> {
|
export async function plansRoutes(app: FastifyInstance): Promise<void> {
|
||||||
app.get('/api/plans', async () => {
|
app.get('/api/plans', async () => {
|
||||||
const files = await readdir(PLANS_DIR)
|
let files: string[]
|
||||||
|
try {
|
||||||
|
files = await readdir(PLANS_DIR)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const mdFiles: { name: string; path: string; size: number }[] = []
|
const mdFiles: { name: string; path: string; size: number }[] = []
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (extname(file) !== '.md') continue
|
if (extname(file) !== '.md') continue
|
||||||
const fullPath = resolve(PLANS_DIR, file)
|
const fullPath = resolve(PLANS_DIR, file)
|
||||||
|
try {
|
||||||
const stats = await stat(fullPath)
|
const stats = await stat(fullPath)
|
||||||
mdFiles.push({ name: file, path: file, size: stats.size })
|
mdFiles.push({ name: file, path: file, size: stats.size })
|
||||||
|
} catch {
|
||||||
|
// skip files that can't be stat'd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mdFiles.sort((a, b) => b.name.localeCompare(a.name))
|
mdFiles.sort((a, b) => b.name.localeCompare(a.name))
|
||||||
@@ -23,19 +36,17 @@ export async function plansRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
app.get<{ Params: { filename: string } }>('/api/plans/:filename', async (request, reply) => {
|
app.get<{ Params: { filename: string } }>('/api/plans/:filename', async (request, reply) => {
|
||||||
const { filename } = request.params
|
const { filename } = request.params
|
||||||
|
|
||||||
if (extname(filename) !== '.md' || filename.includes('..') || filename.includes('/')) {
|
if (extname(filename) !== '.md' || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
return reply.status(400).send({ error: 'Invalid filename' })
|
return reply.status(400).send({ error: 'Invalid filename' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullPath = resolve(PLANS_DIR, filename)
|
const fullPath = resolve(PLANS_DIR, filename)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await stat(fullPath)
|
const content = await readFile(fullPath, 'utf-8')
|
||||||
|
return { name: filename, content }
|
||||||
} catch {
|
} catch {
|
||||||
return reply.status(404).send({ error: 'File not found' })
|
return reply.status(404).send({ error: 'File not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await readFile(fullPath, 'utf-8')
|
|
||||||
return { name: filename, content }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+41
-39
@@ -9,10 +9,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-pro": "^2.3.0",
|
"crypto-pro": "^2.3.0",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
|
"marked": "^12.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -1104,9 +1107,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1121,9 +1121,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1138,9 +1135,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1155,9 +1149,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1172,9 +1163,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1189,9 +1177,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1206,9 +1191,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1223,9 +1205,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1240,9 +1219,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1257,9 +1233,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1274,9 +1247,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1291,9 +1261,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1308,9 +1275,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1446,6 +1410,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1480,6 +1454,13 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1764,6 +1745,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.389",
|
"version": "1.5.389",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
||||||
@@ -2342,6 +2332,18 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "12.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
|
||||||
|
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-pro": "^2.3.0",
|
"crypto-pro": "^2.3.0",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
|
"marked": "^12.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0"
|
||||||
"marked": "^12.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'
|
|||||||
import { AuthPage } from './pages/AuthPage'
|
import { AuthPage } from './pages/AuthPage'
|
||||||
import { CheckCodesPage } from './pages/CheckCodesPage'
|
import { CheckCodesPage } from './pages/CheckCodesPage'
|
||||||
import { UploadUpdPage } from './pages/UploadUpdPage'
|
import { UploadUpdPage } from './pages/UploadUpdPage'
|
||||||
|
import { PlansPage } from './pages/PlansPage'
|
||||||
import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api'
|
import { setOnUnauthorized, logout as apiLogout, getEnv, setEnv, getAuthStatus } from './services/api'
|
||||||
import type { ApiEnv } from './types'
|
import type { ApiEnv } from './types'
|
||||||
|
|
||||||
type Page = 'auth' | 'check' | 'upload'
|
type Page = 'auth' | 'check' | 'upload' | 'plans'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<Page>('auth')
|
const [page, setPage] = useState<Page>('auth')
|
||||||
@@ -77,6 +78,10 @@ export default function App() {
|
|||||||
disabled={!authenticated}>
|
disabled={!authenticated}>
|
||||||
Загрузка УПД
|
Загрузка УПД
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setPage('plans')}
|
||||||
|
style={navBtnStyle(page === 'plans')}>
|
||||||
|
Планы
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div style={{ marginLeft: 'auto', fontSize: 13, display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ marginLeft: 'auto', fontSize: 13, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<label>
|
<label>
|
||||||
@@ -119,6 +124,7 @@ export default function App() {
|
|||||||
{page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />}
|
{page === 'auth' && <AuthPage key={env} onAuth={handleAuth} />}
|
||||||
{page === 'check' && <CheckCodesPage key={env} />}
|
{page === 'check' && <CheckCodesPage key={env} />}
|
||||||
{page === 'upload' && <UploadUpdPage key={env} />}
|
{page === 'upload' && <UploadUpdPage key={env} />}
|
||||||
|
{page === 'plans' && <PlansPage key={env} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { getPlansList, getPlanContent } from '../services/api'
|
||||||
|
|
||||||
|
export function PlansPage() {
|
||||||
|
const [plans, setPlans] = useState<{ name: string; path: string; size: number }[]>([])
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [listLoading, setListLoading] = useState(true)
|
||||||
|
const [contentLoading, setContentLoading] = useState(false)
|
||||||
|
const [listError, setListError] = useState('')
|
||||||
|
const [contentError, setContentError] = useState('')
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setListLoading(true)
|
||||||
|
getPlansList()
|
||||||
|
.then(setPlans)
|
||||||
|
.catch(e => setListError(e.message))
|
||||||
|
.finally(() => setListLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => abortRef.current?.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(async (filename: string) => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
|
||||||
|
setSelected(filename)
|
||||||
|
setContentLoading(true)
|
||||||
|
setContentError('')
|
||||||
|
try {
|
||||||
|
const res = await getPlanContent(filename)
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setContent(res.content)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setContentError(e.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setContentLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const rawHtml = useMemo(() => {
|
||||||
|
if (!content) return ''
|
||||||
|
return marked.parse(content, { async: false }) as string
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
const sanitizedHtml = useMemo(() => {
|
||||||
|
return DOMPurify.sanitize(rawHtml)
|
||||||
|
}, [rawHtml])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 24, minHeight: '70vh' }}>
|
||||||
|
<aside style={{ width: 280, flexShrink: 0 }}>
|
||||||
|
<h2 style={{ fontSize: 16, marginTop: 0 }}>Планы</h2>
|
||||||
|
{listLoading && <p style={{ color: '#888' }}>Загрузка...</p>}
|
||||||
|
{!listLoading && plans.length === 0 && !listError && (
|
||||||
|
<p style={{ color: '#888' }}>Нет файлов планов</p>
|
||||||
|
)}
|
||||||
|
{listError && <p style={{ color: '#c62828' }}>{listError}</p>}
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{plans.map(p => (
|
||||||
|
<li key={p.name} style={{ marginBottom: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(p.name)}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
background: selected === p.name ? '#e3f2fd' : 'transparent',
|
||||||
|
border: selected === p.name ? '1px solid #90caf9' : '1px solid transparent',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1565c0',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{contentLoading && <p style={{ color: '#888' }}>Загрузка содержимого...</p>}
|
||||||
|
{!contentLoading && !selected && (
|
||||||
|
<p style={{ color: '#888', marginTop: 40, textAlign: 'center' }}>
|
||||||
|
Выберите план из списка слева
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!contentLoading && selected && contentError && (
|
||||||
|
<p style={{ color: '#c62828' }}>Ошибка: {contentError}</p>
|
||||||
|
)}
|
||||||
|
{!contentLoading && selected && !contentError && (
|
||||||
|
<div
|
||||||
|
className="plan-content"
|
||||||
|
style={{ lineHeight: 1.7, fontSize: 14 }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -102,5 +102,5 @@ export async function getPlansList(): Promise<{ name: string; path: string; size
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlanContent(filename: string): Promise<{ name: string; content: string }> {
|
export async function getPlanContent(filename: string): Promise<{ name: string; content: string }> {
|
||||||
return request(`/api/plans/${filename}`)
|
return request(`/api/plans/${encodeURIComponent(filename)}`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user