diff --git a/backend/server.js b/backend/server.js index b256633..5af3eee 100644 --- a/backend/server.js +++ b/backend/server.js @@ -607,6 +607,28 @@ function runMigrations() { } ); } + + // Проверяем существование колонки is_public_profile + const hasIsPublicProfile = columns.some( + (col) => col.name === "is_public_profile" + ); + if (!hasIsPublicProfile) { + db.run( + "ALTER TABLE users ADD COLUMN is_public_profile INTEGER DEFAULT 0", + (err) => { + if (err) { + console.error( + "Ошибка добавления колонки is_public_profile:", + err.message + ); + } else { + console.log( + "Колонка is_public_profile добавлена в таблицу users" + ); + } + } + ); + } }); // Проверяем существование колонок в таблице notes и добавляем их если нужно @@ -879,7 +901,7 @@ app.get("/api/user", requireApiAuth, (req, res) => { } const sql = - "SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled, two_factor_enabled FROM users WHERE id = ?"; + "SELECT username, email, avatar, accent_color, show_edit_date, colored_icons, floating_toolbar_enabled, two_factor_enabled, is_public_profile FROM users WHERE id = ?"; db.get(sql, [req.session.userId], (err, user) => { if (err) { console.error("Ошибка получения данных пользователя:", err.message); @@ -894,6 +916,208 @@ app.get("/api/user", requireApiAuth, (req, res) => { }); }); +// Публичный API для получения профиля пользователя по логину +app.get("/api/public/user/:username", (req, res) => { + const { username } = req.params; + + const sql = + "SELECT username, avatar, is_public_profile FROM users WHERE username = ?"; + db.get(sql, [username], (err, user) => { + if (err) { + console.error("Ошибка получения публичного профиля:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!user) { + return res.status(404).json({ error: "Пользователь не найден" }); + } + + if (user.is_public_profile !== 1) { + return res.status(403).json({ error: "Профиль не является публичным" }); + } + + // Возвращаем только публичную информацию + res.json({ + username: user.username, + avatar: user.avatar, + }); + }); +}); + +// Публичный API для получения изображения заметки (для публичных профилей) +app.get("/api/public/notes/:id/images/:imageId", (req, res) => { + const { id, imageId } = req.params; + + // Проверяем, что заметка принадлежит пользователю с публичным профилем + const checkNoteSql = ` + SELECT n.user_id, u.is_public_profile + FROM notes n + INNER JOIN users u ON n.user_id = u.id + WHERE n.id = ? AND n.is_archived = 0 + `; + db.get(checkNoteSql, [id], (err, note) => { + if (err) { + console.error("Ошибка проверки заметки:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!note) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (note.is_public_profile !== 1) { + return res.status(403).json({ error: "Профиль не является публичным" }); + } + + // Получаем информацию об изображении + const imageSql = "SELECT file_path FROM note_images WHERE id = ? AND note_id = ?"; + db.get(imageSql, [imageId, id], (err, image) => { + if (err) { + console.error("Ошибка получения изображения:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!image) { + return res.status(404).json({ error: "Изображение не найдено" }); + } + + const imagePath = path.join(__dirname, "public", image.file_path); + if (fs.existsSync(imagePath)) { + res.sendFile(imagePath); + } else { + res.status(404).json({ error: "Файл изображения не найден" }); + } + }); + }); +}); + +// Публичный API для получения файла заметки (для публичных профилей) +app.get("/api/public/notes/:id/files/:fileId", (req, res) => { + const { id, fileId } = req.params; + + // Проверяем, что заметка принадлежит пользователю с публичным профилем + const checkNoteSql = ` + SELECT n.user_id, u.is_public_profile + FROM notes n + INNER JOIN users u ON n.user_id = u.id + WHERE n.id = ? AND n.is_archived = 0 + `; + db.get(checkNoteSql, [id], (err, note) => { + if (err) { + console.error("Ошибка проверки заметки:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!note) { + return res.status(404).json({ error: "Заметка не найдена" }); + } + + if (note.is_public_profile !== 1) { + return res.status(403).json({ error: "Профиль не является публичным" }); + } + + // Получаем информацию о файле + const fileSql = "SELECT file_path, original_name FROM note_files WHERE id = ? AND note_id = ?"; + db.get(fileSql, [fileId, id], (err, file) => { + if (err) { + console.error("Ошибка получения файла:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!file) { + return res.status(404).json({ error: "Файл не найден" }); + } + + const filePath = path.join(__dirname, "public", file.file_path); + if (fs.existsSync(filePath)) { + res.download(filePath, file.original_name); + } else { + res.status(404).json({ error: "Файл не найден" }); + } + }); + }); +}); + +// Публичный API для получения заметок пользователя по логину +app.get("/api/public/user/:username/notes", (req, res) => { + const { username } = req.params; + + // Сначала проверяем, что пользователь существует и профиль публичный + const checkUserSql = + "SELECT id, is_public_profile FROM users WHERE username = ?"; + db.get(checkUserSql, [username], (err, user) => { + if (err) { + console.error("Ошибка проверки пользователя:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if (!user) { + return res.status(404).json({ error: "Пользователь не найден" }); + } + + if (user.is_public_profile !== 1) { + return res.status(403).json({ error: "Профиль не является публичным" }); + } + + // Получаем публичные заметки пользователя (не архивные) + const sql = ` + SELECT + n.*, + CASE + WHEN COUNT(DISTINCT ni.id) = 0 THEN '[]' + ELSE json_group_array( + DISTINCT json_object( + 'id', ni.id, + 'filename', ni.filename, + 'original_name', ni.original_name, + 'file_path', ni.file_path, + 'file_size', ni.file_size, + 'mime_type', ni.mime_type, + 'created_at', ni.created_at + ) + ) FILTER (WHERE ni.id IS NOT NULL) + END as images, + CASE + WHEN COUNT(DISTINCT nf.id) = 0 THEN '[]' + ELSE json_group_array( + DISTINCT json_object( + 'id', nf.id, + 'filename', nf.filename, + 'original_name', nf.original_name, + 'file_path', nf.file_path, + 'file_size', nf.file_size, + 'mime_type', nf.mime_type, + 'created_at', nf.created_at + ) + ) FILTER (WHERE nf.id IS NOT NULL) + END as files + FROM notes n + LEFT JOIN note_images ni ON n.id = ni.note_id + LEFT JOIN note_files nf ON n.id = nf.note_id + WHERE n.user_id = ? AND n.is_archived = 0 + GROUP BY n.id + ORDER BY n.is_pinned DESC, n.pinned_at DESC, n.created_at DESC + `; + + db.all(sql, [user.id], (err, rows) => { + if (err) { + console.error("Ошибка получения публичных заметок:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + // Парсим JSON строки изображений и файлов + const notesWithImagesAndFiles = rows.map((row) => ({ + ...row, + content: decrypt(row.content), // Дешифруем содержимое заметки + images: row.images === "[]" ? [] : JSON.parse(row.images), + files: row.files === "[]" ? [] : JSON.parse(row.files), + })); + + res.json(notesWithImagesAndFiles); + }); + }); +}); + // API для получения статуса 2FA app.get("/api/user/2fa/status", requireApiAuth, (req, res) => { const userId = req.session.userId; @@ -915,6 +1139,13 @@ app.get("/api/user/2fa/status", requireApiAuth, (req, res) => { }); }); +// Публичная страница профиля (не требует аутентификации) +app.get("/public/:username", (req, res) => { + // Просто возвращаем index.html для SPA + // React Router на фронтенде обработает маршрут + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + // Страница с заметками (требует аутентификации) app.get("/notes", requireAuth, (req, res) => { // Получаем цвет пользователя для предотвращения FOUC @@ -1739,6 +1970,7 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => { show_edit_date, colored_icons, floating_toolbar_enabled, + is_public_profile, } = req.body; const userId = req.session.userId; @@ -1819,6 +2051,11 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => { params.push(floating_toolbar_enabled ? 1 : 0); } + if (is_public_profile !== undefined) { + updateFields.push("is_public_profile = ?"); + params.push(is_public_profile ? 1 : 0); + } + if (newPassword) { const hashedPassword = await bcrypt.hash(newPassword, 10); updateFields.push("password = ?"); @@ -1856,6 +2093,7 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => { if (show_edit_date !== undefined) changes.push("настройка показа даты редактирования"); if (colored_icons !== undefined) changes.push("цветные иконки"); + if (is_public_profile !== undefined) changes.push("публичный профиль"); if (newPassword) changes.push("пароль"); const details = `Обновлен профиль: ${changes.join(", ")}`; logAction(userId, "profile_update", details); diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 7e9829b..a8f9061 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "/index.html", - "revision": "0.1kpib446v2o" + "revision": "0.kpjpajqk6vo" }], { "ignoreURLParametersMatching": [/^utm_/, /^fbclid$/] }); diff --git a/src/App.tsx b/src/App.tsx index 9b7c16e..2ad4f53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import RegisterPage from "./pages/RegisterPage"; import NotesPage from "./pages/NotesPage"; import ProfilePage from "./pages/ProfilePage"; import SettingsPage from "./pages/SettingsPage"; +import PublicProfilePage from "./pages/PublicProfilePage"; import { NotificationStack } from "./components/common/Notification"; import { InstallPrompt } from "./components/common/InstallPrompt"; import { ProtectedRoute } from "./components/ProtectedRoute"; @@ -47,6 +48,7 @@ const AppContent: React.FC = () => { } /> + } /> } /> diff --git a/src/api/notesApi.ts b/src/api/notesApi.ts index 93ff961..adad280 100644 --- a/src/api/notesApi.ts +++ b/src/api/notesApi.ts @@ -109,6 +109,12 @@ export const notesApi = { const { data } = await axiosClient.get("/notes/version"); return data; }, + + // Публичный метод для получения заметок пользователя (не требует аутентификации) + getPublicNotes: async (username: string): Promise => { + const { data } = await axiosClient.get(`/public/user/${username}/notes`); + return data; + }, }; export interface Log { diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 2342669..4708ff7 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -16,13 +16,14 @@ export const userApi = { }, updateProfile: async ( - profile: Omit, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled'> & { + profile: Omit, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled' | 'is_public_profile'> & { currentPassword?: string; newPassword?: string; accent_color?: string; show_edit_date?: boolean; colored_icons?: boolean; floating_toolbar_enabled?: boolean; + is_public_profile?: boolean; } ) => { const { data } = await axiosClient.put("/user/profile", profile); @@ -107,4 +108,10 @@ export const userApi = { ); return data; }, + + // Публичные методы (не требуют аутентификации) + getPublicProfile: async (username: string): Promise<{ username: string; avatar?: string }> => { + const { data } = await axiosClient.get<{ username: string; avatar?: string }>(`/public/user/${username}`); + return data; + }, }; diff --git a/src/components/notes/PublicNoteItem.tsx b/src/components/notes/PublicNoteItem.tsx new file mode 100644 index 0000000..67d1e32 --- /dev/null +++ b/src/components/notes/PublicNoteItem.tsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Icon } from "@iconify/react"; +import { Note } from "../../types/note"; +import { + parseMarkdown, + makeTagsClickable, +} from "../../utils/markdown"; +import { parseSQLiteUtc, formatLocalDateTime } from "../../utils/dateFormat"; +import { useMarkdown } from "../../hooks/useMarkdown"; + +interface PublicNoteItemProps { + note: Note; + onImageClick: (imageUrl: string) => void; +} + +export const PublicNoteItem: React.FC = ({ + note, + onImageClick, +}) => { + useMarkdown(); // Инициализируем обработчики спойлеров и внешних ссылок + const textNoteRef = useRef(null); + const [isLongNote, setIsLongNote] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + // Форматируем дату для отображения + const formatDate = () => { + const createdDate = parseSQLiteUtc(note.created_at); + return formatLocalDateTime(createdDate); + }; + + // Форматируем содержимое заметки + const formatContent = () => { + let content = note.content; + // Делаем теги кликабельными (для публичной страницы они не будут работать, но стиль сохраним) + content = makeTagsClickable(content); + // Парсим markdown с флагом read-only (чтобы чекбоксы были disabled) + const htmlContent = parseMarkdown(content, true); + return htmlContent; + }; + + const toggleExpand = () => { + setIsExpanded(!isExpanded); + }; + + // Определяем длинные заметки для сворачивания + useEffect(() => { + const checkNoteLength = () => { + if (!textNoteRef.current) return; + const scrollHeight = textNoteRef.current.scrollHeight; + // Считаем заметку длинной, если она больше 300px + const isLong = scrollHeight > 300; + setIsLongNote(isLong); + }; + + const timer = setTimeout(checkNoteLength, 100); + return () => clearTimeout(timer); + }, [note.content]); + + // Получаем иконку файла по расширению + const getFileIcon = (filename: string): string => { + const ext = filename.split(".").pop()?.toLowerCase(); + const iconMap: { [key: string]: string } = { + pdf: "mdi:file-pdf-box", + doc: "mdi:file-word-box", + docx: "mdi:file-word-box", + xls: "mdi:file-excel-box", + xlsx: "mdi:file-excel-box", + txt: "mdi:file-document-outline", + zip: "mdi:folder-zip", + rar: "mdi:folder-zip", + "7z": "mdi:folder-zip", + }; + return iconMap[ext || ""] || "mdi:file"; + }; + + // Форматируем размер файла + const formatFileSize = (bytes: number): string => { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + }; + + return ( +
+
+ + {formatDate()} + {note.is_pinned === 1 && ( + + + Закреплено + + )} + +
+ +
{ + // Обработка клика по тегам (для публичной страницы не работает, но стиль сохраняем) + const target = e.target as HTMLElement; + if (target.classList.contains("tag-in-note")) { + // Для публичной страницы теги не кликабельны + e.preventDefault(); + } + }} + /> + {isLongNote && ( + + )} + + {note.images && note.images.length > 0 && ( +
+ {note.images.map((image) => { + // Используем публичный endpoint для изображений + const imageUrl = `/api/public/notes/${note.id}/images/${image.id}`; + return ( +
+ {image.original_name} onImageClick(imageUrl)} + /> +
+ ); + })} +
+ )} + + {note.files && note.files.length > 0 && ( +
+ {note.files.map((file) => { + // Используем публичный endpoint для файлов + const fileUrl = `/api/public/notes/${note.id}/files/${file.id}`; + return ( + + ); + })} +
+ )} +
+ ); +}; + diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index f19f1c2..fc3d26f 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -38,6 +38,10 @@ const ProfilePage: React.FC = () => { const [twoFactorEnabled, setTwoFactorEnabled] = useState(false); const [isLoading2FA, setIsLoading2FA] = useState(false); + // Public profile settings + const [isPublicProfile, setIsPublicProfile] = useState(false); + const [publicProfileLink, setPublicProfileLink] = useState(""); + const avatarInputRef = useRef(null); useEffect(() => { @@ -82,6 +86,15 @@ const ProfilePage: React.FC = () => { setHasAvatar(false); } + // Устанавливаем состояние публичного профиля + setIsPublicProfile(userData.is_public_profile === 1); + if (userData.is_public_profile === 1) { + const link = `${window.location.origin}/public/${userData.username}`; + setPublicProfileLink(link); + } else { + setPublicProfileLink(""); + } + // Загружаем AI настройки try { const aiSettings = await userApi.getAiSettings(); @@ -258,6 +271,38 @@ const ProfilePage: React.FC = () => { return re.test(email); }; + const handlePublicProfileToggle = async () => { + const newValue = !isPublicProfile; + try { + await userApi.updateProfile({ + is_public_profile: newValue, + }); + setIsPublicProfile(newValue); + if (newValue) { + const link = `${window.location.origin}/public/${username}`; + setPublicProfileLink(link); + showNotification("Публичный профиль включен", "success"); + } else { + setPublicProfileLink(""); + showNotification("Публичный профиль отключен", "success"); + } + await loadProfile(); + } catch (error: any) { + console.error("Ошибка обновления публичного профиля:", error); + showNotification( + error.response?.data?.error || "Ошибка обновления публичного профиля", + "error" + ); + } + }; + + const copyPublicProfileLink = () => { + if (publicProfileLink) { + navigator.clipboard.writeText(publicProfileLink); + showNotification("Ссылка скопирована в буфер обмена", "success"); + } + }; + return (
@@ -418,6 +463,91 @@ const ProfilePage: React.FC = () => {
+

Публичный профиль

+
+ + {isPublicProfile && publicProfileLink && ( +
+

+ Ссылка на ваш публичный профиль: +

+
+ + +
+
+ )} +
+ +
+

Безопасность

{isLoading2FA ? (

Загрузка...

diff --git a/src/pages/PublicProfilePage.tsx b/src/pages/PublicProfilePage.tsx new file mode 100644 index 0000000..2a69f84 --- /dev/null +++ b/src/pages/PublicProfilePage.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Icon } from "@iconify/react"; +import { userApi } from "../api/userApi"; +import { notesApi } from "../api/notesApi"; +import { Note } from "../types/note"; +import { PublicNoteItem } from "../components/notes/PublicNoteItem"; +import { ImageModal } from "../components/common/ImageModal"; +import { ThemeToggle } from "../components/common/ThemeToggle"; +import { useNotification } from "../hooks/useNotification"; +import { useTheme } from "../hooks/useTheme"; + +const PublicProfilePage: React.FC = () => { + const { username } = useParams<{ username: string }>(); + const navigate = useNavigate(); + const { showNotification } = useNotification(); + const { theme } = useTheme(); + + const [profile, setProfile] = useState<{ username: string; avatar?: string } | null>(null); + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedImage, setSelectedImage] = useState(null); + const [selectedNote, setSelectedNote] = useState(null); + const [imageIndex, setImageIndex] = useState(0); + + useEffect(() => { + if (username) { + loadPublicProfile(); + } + }, [username]); + + const loadPublicProfile = async () => { + if (!username) return; + + setIsLoading(true); + setError(null); + + try { + // Загружаем профиль пользователя + const profileData = await userApi.getPublicProfile(username); + setProfile(profileData); + + // Загружаем публичные заметки + const notesData = await notesApi.getPublicNotes(username); + setNotes(notesData); + } catch (error: any) { + console.error("Ошибка загрузки публичного профиля:", error); + if (error.response?.status === 404) { + setError("Пользователь не найден"); + } else if (error.response?.status === 403) { + setError("Профиль не является публичным"); + } else { + setError("Ошибка загрузки профиля"); + } + } finally { + setIsLoading(false); + } + }; + + const handleImageClick = (imageUrl: string, note: Note) => { + // Извлекаем imageId из URL для определения индекса + const imageIdMatch = imageUrl.match(/\/images\/(\d+)/); + if (imageIdMatch) { + const imageId = parseInt(imageIdMatch[1]); + const index = note.images.findIndex((img) => img.id === imageId); + setImageIndex(index >= 0 ? index : 0); + } else { + setImageIndex(0); + } + setSelectedImage(imageUrl); + setSelectedNote(note); + }; + + const handleCloseImageModal = () => { + setSelectedImage(null); + setSelectedNote(null); + }; + + if (isLoading) { + return ( +
+
+
+ +

Загрузка...

+
+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +

Ошибка

+

{error}

+ +
+
+
+ ); + } + + if (!profile) { + return null; + } + + // Сортируем заметки: сначала закрепленные, потом по дате создания (новые сверху) + const sortedNotes = [...notes].sort((a, b) => { + if (a.is_pinned !== b.is_pinned) { + return b.is_pinned - a.is_pinned; + } + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateB - dateA; + }); + + return ( + <> +
+
+
+
+
+ {profile.avatar ? ( + {profile.username} + ) : ( +
+ +
+ )} + + {profile.username} + +
+
+
+ + +
+
+
+

+ {notes.length} {notes.length === 1 ? "заметка" : notes.length < 5 ? "заметки" : "заметок"} +

+
+
+ {sortedNotes.length === 0 ? ( +
+

У пользователя пока нет публичных заметок

+
+ ) : ( +
+ {sortedNotes.map((note) => ( + handleImageClick(imageUrl, note)} + /> + ))} +
+ )} +
+ + {/* Модальное окно для изображений */} + {selectedImage && selectedNote && ( + `/api/public/notes/${selectedNote.id}/images/${img.id}`)} + currentIndex={imageIndex} + onClose={handleCloseImageModal} + /> + )} + + ); +}; + +export default PublicProfilePage; + diff --git a/src/styles/style.css b/src/styles/style.css index 59205f4..3f36b13 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -1524,6 +1524,12 @@ textarea:focus { top: -1px; } +/* Стили для disabled чекбоксов (в публичных профилях) */ +.textNote input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.6; +} + /* Стили для элементов списка с чекбоксами (task-list-item создается marked.js) */ .textNote .task-list-item { list-style-type: none; diff --git a/src/types/user.ts b/src/types/user.ts index dd0215c..0e0ab3a 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -7,6 +7,7 @@ export interface User { colored_icons?: number; floating_toolbar_enabled?: number; two_factor_enabled?: number; + is_public_profile?: number; } export interface AuthResponse { diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 8afd1f0..770ff02 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -31,9 +31,13 @@ const spoilerExtension = { function renderTokens(tokens: any[], renderer: any): string { return tokens .map((token) => { - // Используем кастомный renderer если он есть - if (renderer[token.type]) { - return renderer[token.type](token); + // Используем кастомный renderer если он есть и является функцией + if (renderer[token.type] && typeof renderer[token.type] === 'function') { + try { + return renderer[token.type](token); + } catch (e) { + // Если метод не может обработать токен, используем fallback + } } // Fallback для встроенных типов токенов if (token.type === "text") { @@ -53,8 +57,12 @@ function renderTokens(tokens: any[], renderer: any): string { } if (token.type === "link") { // Для ссылок используем кастомный renderer если доступен - if (renderer.link) { - return renderer.link(token); + if (renderer.link && typeof renderer.link === 'function') { + try { + return renderer.link(token); + } catch (e) { + // Если метод не может обработать токен, используем fallback + } } // Fallback для встроенных ссылок const href = token.href || ""; @@ -67,8 +75,12 @@ function renderTokens(tokens: any[], renderer: any): string { } if (token.type === "spoiler") { // Для спойлеров используем кастомный renderer если доступен - if (renderer.spoiler) { - return renderer.spoiler(token); + if (renderer.spoiler && typeof renderer.spoiler === 'function') { + try { + return renderer.spoiler(token); + } catch (e) { + // Если метод не может обработать токен, используем fallback + } } return `${ token.text || "" @@ -145,14 +157,20 @@ const highlightCode = (code: string, lang?: string): string => { }; // Кастомный renderer для внешних ссылок, чекбоксов и блоков кода -const renderer: any = { - link(token: any) { +const createRenderer = (isReadOnly: boolean = false): any => { + // Создаем новый renderer, расширяя стандартный + const renderer = new marked.Renderer(); + + // Переопределяем метод link для обработки внешних ссылок + const originalLink = renderer.link.bind(renderer); + renderer.link = function(token: any) { const href = token.href; const title = token.title; - // Правильно обрабатываем вложенные токены для форматирования внутри ссылок + // Используем стандартный метод для рендеринга текста ссылки let text = ""; if (token.tokens && token.tokens.length > 0) { + // Используем стандартные методы marked для рендеринга вложенных токенов text = renderTokens(token.tokens, this); } else if (token.text) { text = token.text; @@ -170,16 +188,18 @@ const renderer: any = { } catch {} return `${text}`; - }, - // Кастомный renderer для элементов списка с чекбоксами - listitem(token: any) { + }; + + // Переопределяем метод listitem для обработки чекбоксов + const originalListitem = renderer.listitem.bind(renderer); + renderer.listitem = function(token: any) { const task = token.task; const checked = token.checked; // Правильно обрабатываем вложенные токены для форматирования let content = ""; if (token.tokens && token.tokens.length > 0) { - // Рендерим вложенные токены используя наш renderer + // Используем стандартные методы marked для рендеринга вложенных токенов content = renderTokens(token.tokens, this); } else if (token.text) { // Если токенов нет, используем текст (для обратной совместимости) @@ -187,13 +207,16 @@ const renderer: any = { } if (task) { - const checkbox = ``; + const disabledAttr = isReadOnly ? " disabled" : ""; + const checkbox = ``; return `
  • ${checkbox} ${content}
  • \n`; } - return `
  • ${content}
  • \n`; - }, - // Кастомный renderer для блоков кода с подсветкой синтаксиса - code(token: any) { + // Для обычных элементов списка используем стандартный метод + return originalListitem(token); + }; + + // Переопределяем метод code для подсветки синтаксиса + renderer.code = function(token: any) { const code = token.text || ""; // В marked токен блока кода имеет поле lang const lang = (token.lang || token.language || "").trim(); @@ -205,19 +228,23 @@ const renderer: any = { const langLabel = lang ? `${lang}` : ""; return `
    ${langLabel}${highlightedCode}
    `; - }, + }; + + return renderer; }; -// Настройка marked +// Настройка marked (базовая конфигурация) marked.use({ extensions: [spoilerExtension], gfm: true, breaks: true, - renderer, }); -export const parseMarkdown = (text: string): string => { - return marked.parse(text) as string; +export const parseMarkdown = (text: string, isReadOnly: boolean = false): string => { + const renderer = createRenderer(isReadOnly); + // Используем marked.parse с renderer + const result = marked.parse(text, { renderer }); + return result as string; }; // Функция для извлечения тегов из текста