Добавлен функционал публичных профилей пользователей. Реализованы API для получения публичной информации о пользователе и его заметках, а также добавлены соответствующие изменения в базу данных. Обновлены компоненты для управления публичным профилем на странице профиля и добавлены стили для отображения состояния публичного профиля. Улучшено взаимодействие с пользователем через уведомления и ссылки на публичные профили.

This commit is contained in:
Fovway 2025-11-08 23:39:28 +07:00
parent 9a1ee8629f
commit 160ae68d1e
11 changed files with 846 additions and 27 deletions

View File

@ -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);

View File

@ -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$/]
});

View File

@ -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 = () => {
</ProtectedRoute>
}
/>
<Route path="/public/:username" element={<PublicProfilePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>

View File

@ -109,6 +109,12 @@ export const notesApi = {
const { data } = await axiosClient.get("/notes/version");
return data;
},
// Публичный метод для получения заметок пользователя (не требует аутентификации)
getPublicNotes: async (username: string): Promise<Note[]> => {
const { data } = await axiosClient.get<Note[]>(`/public/user/${username}/notes`);
return data;
},
};
export interface Log {

View File

@ -16,13 +16,14 @@ export const userApi = {
},
updateProfile: async (
profile: Omit<Partial<User>, 'show_edit_date' | 'colored_icons' | 'floating_toolbar_enabled'> & {
profile: Omit<Partial<User>, '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;
},
};

View File

@ -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<PublicNoteItemProps> = ({
note,
onImageClick,
}) => {
useMarkdown(); // Инициализируем обработчики спойлеров и внешних ссылок
const textNoteRef = useRef<HTMLDivElement>(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 (
<div
className={`container ${note.is_pinned === 1 ? "note-pinned" : ""}`}
data-note-id={note.id}
>
<div className="date">
<span className="date-text">
{formatDate()}
{note.is_pinned === 1 && (
<span className="pin-indicator">
<Icon icon="mdi:pin" />
Закреплено
</span>
)}
</span>
</div>
<div
ref={textNoteRef}
className={`textNote ${isLongNote && !isExpanded ? "collapsed" : ""}`}
data-original-content={note.content}
dangerouslySetInnerHTML={{ __html: formatContent() }}
onClick={(e) => {
// Обработка клика по тегам (для публичной страницы не работает, но стиль сохраняем)
const target = e.target as HTMLElement;
if (target.classList.contains("tag-in-note")) {
// Для публичной страницы теги не кликабельны
e.preventDefault();
}
}}
/>
{isLongNote && (
<button
className="show-more-btn"
onClick={toggleExpand}
type="button"
>
<Icon
icon={isExpanded ? "mdi:chevron-up" : "mdi:chevron-down"}
/>
<span>{isExpanded ? "Скрыть" : "Раскрыть"}</span>
</button>
)}
{note.images && note.images.length > 0 && (
<div className="note-images-container">
{note.images.map((image) => {
// Используем публичный endpoint для изображений
const imageUrl = `/api/public/notes/${note.id}/images/${image.id}`;
return (
<div key={image.id} className="note-image-item">
<img
src={imageUrl}
alt={image.original_name}
className="note-image lazy"
data-src={imageUrl}
data-image-id={image.id}
loading="lazy"
onClick={() => onImageClick(imageUrl)}
/>
</div>
);
})}
</div>
)}
{note.files && note.files.length > 0 && (
<div className="note-files-container">
{note.files.map((file) => {
// Используем публичный endpoint для файлов
const fileUrl = `/api/public/notes/${note.id}/files/${file.id}`;
return (
<div key={file.id} className="note-file-item">
<a
href={fileUrl}
download={file.original_name}
className="note-file-link"
data-file-id={file.id}
>
<Icon
icon={getFileIcon(file.original_name)}
className="file-icon"
/>
<div className="file-info">
<div className="file-name">{file.original_name}</div>
<div className="file-size">
{formatFileSize(file.file_size)}
</div>
</div>
</a>
</div>
);
})}
</div>
)}
</div>
);
};

View File

@ -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<HTMLInputElement>(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 (
<div className="container">
<header className="notes-header">
@ -418,6 +463,91 @@ const ProfilePage: React.FC = () => {
<hr className="separator" />
<h3>Публичный профиль</h3>
<div className="form-group">
<label
className="ai-toggle-label"
style={{ marginBottom: "10px", cursor: "pointer" }}
onClick={handlePublicProfileToggle}
>
<div className="toggle-label-content">
<span className="toggle-text-main">Включить публичный профиль</span>
<span className="toggle-text-desc">
При включенном публичном профиле любой пользователь сможет
просматривать ваши заметки по ссылке с вашим логином.
</span>
</div>
<div className="toggle-switch-wrapper">
<input
type="checkbox"
id="publicProfileToggle"
className="toggle-checkbox"
checked={isPublicProfile}
onChange={handlePublicProfileToggle}
disabled
style={{ pointerEvents: "none" }}
/>
<span className="toggle-slider"></span>
</div>
</label>
{isPublicProfile && publicProfileLink && (
<div
style={{
marginTop: "15px",
padding: "15px",
backgroundColor: "var(--bg-tertiary, #f5f5f5)",
borderRadius: "5px",
border: "1px solid var(--border-secondary, #ddd)",
}}
>
<p
style={{
marginBottom: "10px",
fontWeight: "bold",
fontSize: "14px",
color: "var(--text-primary, #333)",
}}
>
Ссылка на ваш публичный профиль:
</p>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<input
type="text"
readOnly
value={publicProfileLink}
style={{
flex: 1,
padding: "8px",
border: "1px solid var(--border-secondary, #ddd)",
borderRadius: "4px",
fontSize: "14px",
backgroundColor: "var(--bg-secondary, #fff)",
color: "var(--text-primary, #333)",
}}
/>
<button
className="btnSave"
onClick={copyPublicProfileLink}
style={{
padding: "8px 15px",
whiteSpace: "nowrap",
}}
>
<Icon icon="mdi:content-copy" /> Копировать
</button>
</div>
</div>
)}
</div>
<hr className="separator" />
<h3>Безопасность</h3>
{isLoading2FA ? (
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>

View File

@ -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<Note[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedNote, setSelectedNote] = useState<Note | null>(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 (
<div className="center">
<div className="container">
<div style={{ textAlign: "center", padding: "50px" }}>
<Icon icon="mdi:loading" style={{ fontSize: "48px" }} />
<p style={{ marginTop: "20px" }}>Загрузка...</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="center">
<div className="container">
<div style={{ textAlign: "center", padding: "50px" }}>
<Icon icon="mdi:alert-circle" style={{ fontSize: "48px", color: "#dc3545" }} />
<h2 style={{ marginTop: "20px" }}>Ошибка</h2>
<p style={{ color: "#666", marginTop: "10px" }}>{error}</p>
<button
className="btnSave"
onClick={() => navigate("/")}
style={{ marginTop: "20px" }}
>
Вернуться на главную
</button>
</div>
</div>
</div>
);
}
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 (
<>
<div className="center">
<div className="container">
<header className="notes-header">
<div className="notes-header-left">
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
{profile.avatar ? (
<img
src={profile.avatar}
alt={profile.username}
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
objectFit: "cover",
}}
/>
) : (
<div
style={{
width: "40px",
height: "40px",
borderRadius: "50%",
backgroundColor: "var(--accent-color, #007bff)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: "20px",
lineHeight: "1",
}}
>
<Icon icon="mdi:account" style={{ margin: 0, padding: 0 }} />
</div>
)}
<span>
<Icon icon="mdi:account" /> {profile.username}
</span>
</div>
</div>
<div className="user-info">
<ThemeToggle />
<button
className="notes-btn"
onClick={() => navigate("/")}
title="На главную"
>
<Icon icon="mdi:home" />
</button>
</div>
</header>
<div style={{
padding: "15px 0",
borderBottom: "1px solid var(--border-primary, #ddd)",
marginBottom: "15px"
}}>
<p style={{ margin: 0, color: "var(--text-secondary, #666)", fontSize: "14px" }}>
{notes.length} {notes.length === 1 ? "заметка" : notes.length < 5 ? "заметки" : "заметок"}
</p>
</div>
</div>
{sortedNotes.length === 0 ? (
<div className="notes-container">
<p className="empty-message">У пользователя пока нет публичных заметок</p>
</div>
) : (
<div className="notes-container">
{sortedNotes.map((note) => (
<PublicNoteItem
key={note.id}
note={note}
onImageClick={(imageUrl) => handleImageClick(imageUrl, note)}
/>
))}
</div>
)}
</div>
{/* Модальное окно для изображений */}
{selectedImage && selectedNote && (
<ImageModal
imageUrl={selectedImage}
images={selectedNote.images.map((img) => `/api/public/notes/${selectedNote.id}/images/${img.id}`)}
currentIndex={imageIndex}
onClose={handleCloseImageModal}
/>
)}
</>
);
};
export default PublicProfilePage;

View File

@ -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;

View File

@ -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 {

View File

@ -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 `<span class="spoiler" title="Нажмите, чтобы показать">${
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 `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
},
// Кастомный 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 = `<input type="checkbox" ${checked ? "checked" : ""} />`;
const disabledAttr = isReadOnly ? " disabled" : "";
const checkbox = `<input type="checkbox" ${checked ? "checked" : ""}${disabledAttr} />`;
return `<li class="task-list-item">${checkbox} ${content}</li>\n`;
}
return `<li>${content}</li>\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 ? `<span class="code-language">${lang}</span>` : "";
return `<pre class="code-block">${langLabel}<code class="hljs ${langClass}">${highlightedCode}</code></pre>`;
},
};
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;
};
// Функция для извлечения тегов из текста