Compare commits
3 Commits
9a1ee8629f
...
a37f5cd2d0
| Author | SHA1 | Date | |
|---|---|---|---|
| a37f5cd2d0 | |||
| 91c6f46fb4 | |||
| 160ae68d1e |
@ -607,6 +607,61 @@ 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 1",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка добавления колонки is_public_profile:",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Колонка is_public_profile добавлена в таблицу users"
|
||||||
|
);
|
||||||
|
// Устанавливаем is_public_profile = 1 для всех существующих пользователей
|
||||||
|
db.run(
|
||||||
|
"UPDATE users SET is_public_profile = 1 WHERE is_public_profile IS NULL OR is_public_profile = 0",
|
||||||
|
(updateErr) => {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка обновления is_public_profile для существующих пользователей:",
|
||||||
|
updateErr.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"is_public_profile установлен в 1 для всех существующих пользователей"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Если колонка уже существует, обновляем существующих пользователей с NULL или 0
|
||||||
|
db.run(
|
||||||
|
"UPDATE users SET is_public_profile = 1 WHERE is_public_profile IS NULL OR is_public_profile = 0",
|
||||||
|
(updateErr) => {
|
||||||
|
if (updateErr) {
|
||||||
|
console.error(
|
||||||
|
"Ошибка обновления is_public_profile для существующих пользователей:",
|
||||||
|
updateErr.message
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"is_public_profile установлен в 1 для всех существующих пользователей"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
// Проверяем существование колонок в таблице notes и добавляем их если нужно
|
||||||
@ -620,6 +675,7 @@ function runMigrations() {
|
|||||||
const hasPinned = columns.some((col) => col.name === "is_pinned");
|
const hasPinned = columns.some((col) => col.name === "is_pinned");
|
||||||
const hasArchived = columns.some((col) => col.name === "is_archived");
|
const hasArchived = columns.some((col) => col.name === "is_archived");
|
||||||
const hasPinnedAt = columns.some((col) => col.name === "pinned_at");
|
const hasPinnedAt = columns.some((col) => col.name === "pinned_at");
|
||||||
|
const hasIsPrivate = columns.some((col) => col.name === "is_private");
|
||||||
|
|
||||||
// Добавляем updated_at если нужно
|
// Добавляем updated_at если нужно
|
||||||
if (!hasUpdatedAt) {
|
if (!hasUpdatedAt) {
|
||||||
@ -685,6 +741,20 @@ function runMigrations() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем is_private если нужно
|
||||||
|
if (!hasIsPrivate) {
|
||||||
|
db.run(
|
||||||
|
"ALTER TABLE notes ADD COLUMN is_private INTEGER DEFAULT 1",
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Ошибка добавления колонки is_private:", err.message);
|
||||||
|
} else {
|
||||||
|
console.log("Колонка is_private добавлена в таблицу notes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем индексы после всех изменений
|
// Создаем индексы после всех изменений
|
||||||
if (hasUpdatedAt && hasPinned && hasArchived) {
|
if (hasUpdatedAt && hasPinned && hasArchived) {
|
||||||
createIndexes();
|
createIndexes();
|
||||||
@ -760,8 +830,8 @@ app.post("/api/register", async (req, res) => {
|
|||||||
// Хешируем пароль
|
// Хешируем пароль
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
// Вставляем пользователя в БД
|
// Вставляем пользователя в БД (is_public_profile по умолчанию 1)
|
||||||
const sql = "INSERT INTO users (username, password) VALUES (?, ?)";
|
const sql = "INSERT INTO users (username, password, is_public_profile) VALUES (?, ?, 1)";
|
||||||
db.run(sql, [username, hashedPassword], function (err) {
|
db.run(sql, [username, hashedPassword], function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
if (err.message.includes("UNIQUE constraint failed")) {
|
if (err.message.includes("UNIQUE constraint failed")) {
|
||||||
@ -879,7 +949,7 @@ app.get("/api/user", requireApiAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sql =
|
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) => {
|
db.get(sql, [req.session.userId], (err, user) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Ошибка получения данных пользователя:", err.message);
|
console.error("Ошибка получения данных пользователя:", err.message);
|
||||||
@ -894,6 +964,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 AND (n.is_private = 0 OR n.is_private IS NULL)
|
||||||
|
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
|
// API для получения статуса 2FA
|
||||||
app.get("/api/user/2fa/status", requireApiAuth, (req, res) => {
|
app.get("/api/user/2fa/status", requireApiAuth, (req, res) => {
|
||||||
const userId = req.session.userId;
|
const userId = req.session.userId;
|
||||||
@ -915,6 +1187,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) => {
|
app.get("/notes", requireAuth, (req, res) => {
|
||||||
// Получаем цвет пользователя для предотвращения FOUC
|
// Получаем цвет пользователя для предотвращения FOUC
|
||||||
@ -1115,7 +1394,7 @@ app.get("/api/notes", requireApiAuth, (req, res) => {
|
|||||||
|
|
||||||
// API для создания новой заметки
|
// API для создания новой заметки
|
||||||
app.post("/api/notes", requireApiAuth, (req, res) => {
|
app.post("/api/notes", requireApiAuth, (req, res) => {
|
||||||
const { content, date, time } = req.body;
|
const { content, date, time, is_private } = req.body;
|
||||||
|
|
||||||
if (!content || !date || !time) {
|
if (!content || !date || !time) {
|
||||||
return res.status(400).json({ error: "Не все поля заполнены" });
|
return res.status(400).json({ error: "Не все поля заполнены" });
|
||||||
@ -1124,9 +1403,12 @@ app.post("/api/notes", requireApiAuth, (req, res) => {
|
|||||||
// Шифруем содержимое заметки перед сохранением
|
// Шифруем содержимое заметки перед сохранением
|
||||||
const encryptedContent = encrypt(content);
|
const encryptedContent = encrypt(content);
|
||||||
|
|
||||||
|
// По умолчанию заметка приватная (is_private = 1)
|
||||||
|
const isPrivateValue = is_private !== undefined ? is_private : 1;
|
||||||
|
|
||||||
const sql =
|
const sql =
|
||||||
"INSERT INTO notes (user_id, content, date, time, created_at, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)";
|
"INSERT INTO notes (user_id, content, date, time, is_private, created_at, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)";
|
||||||
const params = [req.session.userId, encryptedContent, date, time];
|
const params = [req.session.userId, encryptedContent, date, time, isPrivateValue];
|
||||||
|
|
||||||
db.run(sql, params, function (err) {
|
db.run(sql, params, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -1144,7 +1426,7 @@ app.post("/api/notes", requireApiAuth, (req, res) => {
|
|||||||
|
|
||||||
// API для обновления заметки
|
// API для обновления заметки
|
||||||
app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
||||||
const { content, skipTimestamp } = req.body;
|
const { content, skipTimestamp, is_private } = req.body;
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@ -1171,10 +1453,24 @@ app.put("/api/notes/:id", requireApiAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Если skipTimestamp=true (обновление чекбокса), не обновляем updated_at
|
// Если skipTimestamp=true (обновление чекбокса), не обновляем updated_at
|
||||||
const updateSql = skipTimestamp
|
let updateSql;
|
||||||
? "UPDATE notes SET content = ? WHERE id = ?"
|
let params;
|
||||||
: "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
|
||||||
const params = [encryptedContent, id];
|
if (is_private !== undefined) {
|
||||||
|
// Обновляем и content, и is_private
|
||||||
|
updateSql = skipTimestamp
|
||||||
|
? "UPDATE notes SET content = ?, is_private = ? WHERE id = ?"
|
||||||
|
: "UPDATE notes SET content = ?, is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||||
|
params = skipTimestamp
|
||||||
|
? [encryptedContent, is_private, id]
|
||||||
|
: [encryptedContent, is_private, id];
|
||||||
|
} else {
|
||||||
|
// Обновляем только content
|
||||||
|
updateSql = skipTimestamp
|
||||||
|
? "UPDATE notes SET content = ? WHERE id = ?"
|
||||||
|
: "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||||
|
params = [encryptedContent, id];
|
||||||
|
}
|
||||||
|
|
||||||
db.run(updateSql, params, function (err) {
|
db.run(updateSql, params, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@ -1739,6 +2035,7 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
|
|||||||
show_edit_date,
|
show_edit_date,
|
||||||
colored_icons,
|
colored_icons,
|
||||||
floating_toolbar_enabled,
|
floating_toolbar_enabled,
|
||||||
|
is_public_profile,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const userId = req.session.userId;
|
const userId = req.session.userId;
|
||||||
|
|
||||||
@ -1819,6 +2116,11 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
|
|||||||
params.push(floating_toolbar_enabled ? 1 : 0);
|
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) {
|
if (newPassword) {
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
updateFields.push("password = ?");
|
updateFields.push("password = ?");
|
||||||
@ -1856,6 +2158,7 @@ app.put("/api/user/profile", requireApiAuth, async (req, res) => {
|
|||||||
if (show_edit_date !== undefined)
|
if (show_edit_date !== undefined)
|
||||||
changes.push("настройка показа даты редактирования");
|
changes.push("настройка показа даты редактирования");
|
||||||
if (colored_icons !== undefined) changes.push("цветные иконки");
|
if (colored_icons !== undefined) changes.push("цветные иконки");
|
||||||
|
if (is_public_profile !== undefined) changes.push("публичный профиль");
|
||||||
if (newPassword) changes.push("пароль");
|
if (newPassword) changes.push("пароль");
|
||||||
const details = `Обновлен профиль: ${changes.join(", ")}`;
|
const details = `Обновлен профиль: ${changes.join(", ")}`;
|
||||||
logAction(userId, "profile_update", details);
|
logAction(userId, "profile_update", details);
|
||||||
|
|||||||
@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "/index.html",
|
"url": "/index.html",
|
||||||
"revision": "0.1kpib446v2o"
|
"revision": "0.umqqd7sqgfg"
|
||||||
}], {
|
}], {
|
||||||
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import RegisterPage from "./pages/RegisterPage";
|
|||||||
import NotesPage from "./pages/NotesPage";
|
import NotesPage from "./pages/NotesPage";
|
||||||
import ProfilePage from "./pages/ProfilePage";
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import PublicProfilePage from "./pages/PublicProfilePage";
|
||||||
import { NotificationStack } from "./components/common/Notification";
|
import { NotificationStack } from "./components/common/Notification";
|
||||||
import { InstallPrompt } from "./components/common/InstallPrompt";
|
import { InstallPrompt } from "./components/common/InstallPrompt";
|
||||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
@ -47,6 +48,7 @@ const AppContent: React.FC = () => {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/public/:username" element={<PublicProfilePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -16,15 +16,16 @@ export const notesApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (note: { content: string; date: string; time: string }) => {
|
create: async (note: { content: string; date: string; time: string; is_private?: 0 | 1 }) => {
|
||||||
const { data } = await axiosClient.post("/notes", note);
|
const { data } = await axiosClient.post("/notes", note);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: number, content: string, skipTimestamp?: boolean) => {
|
update: async (id: number, content: string, skipTimestamp?: boolean, is_private?: 0 | 1) => {
|
||||||
const { data } = await axiosClient.put(`/notes/${id}`, {
|
const { data } = await axiosClient.put(`/notes/${id}`, {
|
||||||
content,
|
content,
|
||||||
skipTimestamp,
|
skipTimestamp,
|
||||||
|
is_private,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@ -109,6 +110,12 @@ export const notesApi = {
|
|||||||
const { data } = await axiosClient.get("/notes/version");
|
const { data } = await axiosClient.get("/notes/version");
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Публичный метод для получения заметок пользователя (не требует аутентификации)
|
||||||
|
getPublicNotes: async (username: string): Promise<Note[]> => {
|
||||||
|
const { data } = await axiosClient.get<Note[]>(`/public/user/${username}/notes`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Log {
|
export interface Log {
|
||||||
|
|||||||
@ -180,6 +180,7 @@ export const offlineNotesApi = {
|
|||||||
content: string;
|
content: string;
|
||||||
date: string;
|
date: string;
|
||||||
time: string;
|
time: string;
|
||||||
|
is_private?: 0 | 1;
|
||||||
}): Promise<Note> => {
|
}): Promise<Note> => {
|
||||||
const online = await isOnline();
|
const online = await isOnline();
|
||||||
const userId = getUserId();
|
const userId = getUserId();
|
||||||
@ -197,6 +198,7 @@ export const offlineNotesApi = {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
is_pinned: 0,
|
is_pinned: 0,
|
||||||
is_archived: 0,
|
is_archived: 0,
|
||||||
|
is_private: note.is_private !== undefined ? note.is_private : 1,
|
||||||
images: [],
|
images: [],
|
||||||
files: [],
|
files: [],
|
||||||
syncStatus: "pending",
|
syncStatus: "pending",
|
||||||
@ -268,6 +270,7 @@ export const offlineNotesApi = {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
is_pinned: 0,
|
is_pinned: 0,
|
||||||
is_archived: 0,
|
is_archived: 0,
|
||||||
|
is_private: note.is_private !== undefined ? note.is_private : 1,
|
||||||
images: [],
|
images: [],
|
||||||
files: [],
|
files: [],
|
||||||
syncStatus: "pending",
|
syncStatus: "pending",
|
||||||
@ -304,7 +307,8 @@ export const offlineNotesApi = {
|
|||||||
update: async (
|
update: async (
|
||||||
id: number | string,
|
id: number | string,
|
||||||
content: string,
|
content: string,
|
||||||
skipTimestamp?: boolean
|
skipTimestamp?: boolean,
|
||||||
|
is_private?: 0 | 1
|
||||||
): Promise<Note> => {
|
): Promise<Note> => {
|
||||||
const online = await isOnline();
|
const online = await isOnline();
|
||||||
|
|
||||||
@ -319,6 +323,7 @@ export const offlineNotesApi = {
|
|||||||
const updatedNote: Note = {
|
const updatedNote: Note = {
|
||||||
...existingNote,
|
...existingNote,
|
||||||
content,
|
content,
|
||||||
|
is_private: is_private !== undefined ? is_private : existingNote.is_private,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
syncStatus: "pending",
|
syncStatus: "pending",
|
||||||
};
|
};
|
||||||
@ -329,7 +334,7 @@ export const offlineNotesApi = {
|
|||||||
await dbManager.addToSyncQueue({
|
await dbManager.addToSyncQueue({
|
||||||
type: "update",
|
type: "update",
|
||||||
noteId: id,
|
noteId: id,
|
||||||
data: { content, skipTimestamp },
|
data: { content, skipTimestamp, is_private },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
retries: 0,
|
retries: 0,
|
||||||
});
|
});
|
||||||
@ -350,6 +355,7 @@ export const offlineNotesApi = {
|
|||||||
const { data } = await axiosClient.put(`/notes/${id}`, {
|
const { data } = await axiosClient.put(`/notes/${id}`, {
|
||||||
content,
|
content,
|
||||||
skipTimestamp,
|
skipTimestamp,
|
||||||
|
is_private,
|
||||||
});
|
});
|
||||||
|
|
||||||
const noteWithSyncStatus = {
|
const noteWithSyncStatus = {
|
||||||
|
|||||||
@ -16,13 +16,14 @@ export const userApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateProfile: async (
|
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;
|
currentPassword?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
accent_color?: string;
|
accent_color?: string;
|
||||||
show_edit_date?: boolean;
|
show_edit_date?: boolean;
|
||||||
colored_icons?: boolean;
|
colored_icons?: boolean;
|
||||||
floating_toolbar_enabled?: boolean;
|
floating_toolbar_enabled?: boolean;
|
||||||
|
is_public_profile?: boolean;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const { data } = await axiosClient.put("/user/profile", profile);
|
const { data } = await axiosClient.put("/user/profile", profile);
|
||||||
@ -107,4 +108,10 @@ export const userApi = {
|
|||||||
);
|
);
|
||||||
return data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,43 +1,74 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
export const ImageModal: React.FC = () => {
|
interface ImageModalProps {
|
||||||
|
imageUrl?: string;
|
||||||
|
images?: string[];
|
||||||
|
currentIndex?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageModal: React.FC<ImageModalProps> = ({
|
||||||
|
imageUrl,
|
||||||
|
images,
|
||||||
|
currentIndex = 0,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [imageSrc, setImageSrc] = useState("");
|
const [imageSrc, setImageSrc] = useState("");
|
||||||
|
const [currentImgIndex, setCurrentImgIndex] = useState(0);
|
||||||
|
|
||||||
|
// Если передан imageUrl через props, используем props-режим
|
||||||
|
const isPropsMode = imageUrl !== undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleImageClick = (e: Event) => {
|
if (isPropsMode) {
|
||||||
const target = e.target as HTMLElement;
|
// Режим с props
|
||||||
if (target.classList.contains("note-image")) {
|
if (imageUrl) {
|
||||||
const src =
|
setImageSrc(imageUrl);
|
||||||
target.getAttribute("src") || target.getAttribute("data-src");
|
setIsOpen(true);
|
||||||
if (src) {
|
setCurrentImgIndex(currentIndex || 0);
|
||||||
setImageSrc(src);
|
} else {
|
||||||
setIsOpen(true);
|
setIsOpen(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
} else {
|
||||||
|
// Режим с event listeners (обратная совместимость)
|
||||||
|
const handleImageClick = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains("note-image")) {
|
||||||
|
const src =
|
||||||
|
target.getAttribute("src") || target.getAttribute("data-src");
|
||||||
|
if (src) {
|
||||||
|
setImageSrc(src);
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("click", handleImageClick);
|
document.addEventListener("click", handleImageClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("click", handleImageClick);
|
document.removeEventListener("click", handleImageClick);
|
||||||
};
|
};
|
||||||
}, []);
|
}
|
||||||
|
}, [isPropsMode, imageUrl, currentIndex]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && isOpen) {
|
if (e.key === "Escape" && isOpen) {
|
||||||
setIsOpen(false);
|
handleClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", handleEscape);
|
document.addEventListener("keydown", handleEscape);
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [isOpen]);
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
@ -45,8 +76,28 @@ export const ImageModal: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNext = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
const nextIndex = (currentImgIndex + 1) % images.length;
|
||||||
|
setCurrentImgIndex(nextIndex);
|
||||||
|
setImageSrc(images[nextIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (images && images.length > 0) {
|
||||||
|
const prevIndex = (currentImgIndex - 1 + images.length) % images.length;
|
||||||
|
setCurrentImgIndex(prevIndex);
|
||||||
|
setImageSrc(images[prevIndex]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const showNavigation = isPropsMode && images && images.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id="imageModal"
|
id="imageModal"
|
||||||
@ -57,6 +108,52 @@ export const ImageModal: React.FC = () => {
|
|||||||
<span className="image-modal-close" onClick={handleClose}>
|
<span className="image-modal-close" onClick={handleClose}>
|
||||||
×
|
×
|
||||||
</span>
|
</span>
|
||||||
|
{showNavigation && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="image-modal-nav image-modal-nav-prev"
|
||||||
|
onClick={handlePrev}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "20px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
background: "rgba(0, 0, 0, 0.5)",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "24px",
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="image-modal-nav image-modal-nav-next"
|
||||||
|
onClick={handleNext}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "20px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
background: "rgba(0, 0, 0, 0.5)",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "50%",
|
||||||
|
width: "50px",
|
||||||
|
height: "50px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "24px",
|
||||||
|
zIndex: 1001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<img
|
<img
|
||||||
className="image-modal-content"
|
className="image-modal-content"
|
||||||
id="modalImage"
|
id="modalImage"
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [images, setImages] = useState<File[]>([]);
|
const [images, setImages] = useState<File[]>([]);
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [isPrivate, setIsPrivate] = useState(true); // По умолчанию включен (приватная)
|
||||||
const [isAiLoading, setIsAiLoading] = useState(false);
|
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||||
const [showTagsModal, setShowTagsModal] = useState(false);
|
const [showTagsModal, setShowTagsModal] = useState(false);
|
||||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||||
@ -64,7 +65,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
|
||||||
const note = await offlineNotesApi.create({ content, date, time });
|
const note = await offlineNotesApi.create({ content, date, time, is_private: isPrivate ? 1 : 0 });
|
||||||
|
|
||||||
// Загружаем изображения
|
// Загружаем изображения
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
@ -80,6 +81,7 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
setContent("");
|
setContent("");
|
||||||
setImages([]);
|
setImages([]);
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
|
setIsPrivate(true); // Сбрасываем тумблер к значению по умолчанию
|
||||||
onSave(note.id);
|
onSave(note.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Ошибка сохранения заметки:", error);
|
console.error("Ошибка сохранения заметки:", error);
|
||||||
@ -1078,6 +1080,22 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
|||||||
<ImageUpload images={images} onChange={setImages} />
|
<ImageUpload images={images} onChange={setImages} />
|
||||||
<FileUpload files={files} onChange={setFiles} />
|
<FileUpload files={files} onChange={setFiles} />
|
||||||
|
|
||||||
|
{user?.is_public_profile === 1 && (
|
||||||
|
<div className="privacy-toggle-container" style={{ marginBottom: "10px", display: "flex", alignItems: "center", gap: "10px" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isPrivate}
|
||||||
|
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||||
|
style={{ width: "18px", height: "18px", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "14px", color: "var(--text-primary, #333)" }}>
|
||||||
|
Приватная заметка
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="save-button-container">
|
<div className="save-button-container">
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
{aiEnabled && (
|
{aiEnabled && (
|
||||||
|
|||||||
@ -47,10 +47,15 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState(note.content);
|
const [editContent, setEditContent] = useState(note.content);
|
||||||
|
const [editIsPrivate, setEditIsPrivate] = useState(
|
||||||
|
note.is_private !== undefined ? note.is_private === 1 : true
|
||||||
|
);
|
||||||
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
const [showArchiveModal, setShowArchiveModal] = useState(false);
|
||||||
const [editImages, setEditImages] = useState<File[]>([]);
|
const [editImages, setEditImages] = useState<File[]>([]);
|
||||||
const [editFiles, setEditFiles] = useState<File[]>([]);
|
const [editFiles, setEditFiles] = useState<File[]>([]);
|
||||||
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>([]);
|
const [deletedImageIds, setDeletedImageIds] = useState<(number | string)[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
|
const [deletedFileIds, setDeletedFileIds] = useState<(number | string)[]>([]);
|
||||||
const [isAiLoading, setIsAiLoading] = useState(false);
|
const [isAiLoading, setIsAiLoading] = useState(false);
|
||||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||||
@ -84,7 +89,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов
|
useMarkdown({ onNoteUpdate: onReload }); // Инициализируем обработчики спойлеров, внешних ссылок и чекбоксов
|
||||||
|
|
||||||
// Проверяем, включена ли плавающая панель
|
// Проверяем, включена ли плавающая панель
|
||||||
const floatingToolbarEnabled =
|
const floatingToolbarEnabled =
|
||||||
user?.floating_toolbar_enabled !== undefined
|
user?.floating_toolbar_enabled !== undefined
|
||||||
@ -94,6 +99,9 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setEditContent(note.content);
|
setEditContent(note.content);
|
||||||
|
setEditIsPrivate(
|
||||||
|
note.is_private !== undefined ? note.is_private === 1 : true
|
||||||
|
);
|
||||||
setEditImages([]);
|
setEditImages([]);
|
||||||
setEditFiles([]);
|
setEditFiles([]);
|
||||||
setDeletedImageIds([]);
|
setDeletedImageIds([]);
|
||||||
@ -116,7 +124,12 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await offlineNotesApi.update(note.id, editContent);
|
await offlineNotesApi.update(
|
||||||
|
note.id,
|
||||||
|
editContent,
|
||||||
|
false,
|
||||||
|
editIsPrivate ? 1 : 0
|
||||||
|
);
|
||||||
|
|
||||||
// Удаляем выбранные изображения
|
// Удаляем выбранные изображения
|
||||||
for (const imageId of deletedImageIds) {
|
for (const imageId of deletedImageIds) {
|
||||||
@ -161,6 +174,9 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
const handleCancelEdit = () => {
|
const handleCancelEdit = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setEditContent(note.content);
|
setEditContent(note.content);
|
||||||
|
setEditIsPrivate(
|
||||||
|
note.is_private !== undefined ? note.is_private === 1 : true
|
||||||
|
);
|
||||||
setEditImages([]);
|
setEditImages([]);
|
||||||
setEditFiles([]);
|
setEditFiles([]);
|
||||||
setDeletedImageIds([]);
|
setDeletedImageIds([]);
|
||||||
@ -248,7 +264,10 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
console.error("Детали ошибки:", error.response?.data);
|
console.error("Детали ошибки:", error.response?.data);
|
||||||
setTagsGenerationError(true);
|
setTagsGenerationError(true);
|
||||||
setShowTagsModal(false);
|
setShowTagsModal(false);
|
||||||
const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов";
|
const errorMessage =
|
||||||
|
error.response?.data?.error ||
|
||||||
|
error.message ||
|
||||||
|
"Ошибка генерации тегов";
|
||||||
showNotification(errorMessage, "error");
|
showNotification(errorMessage, "error");
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingTags(false);
|
setIsGeneratingTags(false);
|
||||||
@ -260,13 +279,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
|
|
||||||
const existingTags = extractTags(editContent);
|
const existingTags = extractTags(editContent);
|
||||||
const tagsToAdd = tags
|
const tagsToAdd = tags
|
||||||
.filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()))
|
.filter(
|
||||||
|
(tag) =>
|
||||||
|
!existingTags.some(
|
||||||
|
(existing) => existing.toLowerCase() === tag.toLowerCase()
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((tag) => `#${tag}`)
|
.map((tag) => `#${tag}`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
if (tagsToAdd) {
|
if (tagsToAdd) {
|
||||||
// Добавляем теги в конец заметки
|
// Добавляем теги в конец заметки
|
||||||
const newContent = editContent.trim() + (editContent.trim() ? "\n\n" : "") + tagsToAdd;
|
const newContent =
|
||||||
|
editContent.trim() + (editContent.trim() ? "\n\n" : "") + tagsToAdd;
|
||||||
setEditContent(newContent);
|
setEditContent(newContent);
|
||||||
showNotification(`Добавлено тегов: ${tags.length}`, "success");
|
showNotification(`Добавлено тегов: ${tags.length}`, "success");
|
||||||
} else {
|
} else {
|
||||||
@ -516,7 +541,9 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Проверяем, является ли это форматированием списка или цитаты
|
// Проверяем, является ли это форматированием списка или цитаты
|
||||||
const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(before);
|
const isListFormatting = /^[-*+]\s|^\d+\.\s|^- \[ \]\s|^>\s/.test(
|
||||||
|
before
|
||||||
|
);
|
||||||
const isMultiline = selectedText.includes("\n");
|
const isMultiline = selectedText.includes("\n");
|
||||||
|
|
||||||
if (isListFormatting && isMultiline) {
|
if (isListFormatting && isMultiline) {
|
||||||
@ -529,7 +556,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
// Пропускаем пустые строки
|
// Пропускаем пустые строки
|
||||||
if (trimmedLine === "") {
|
if (trimmedLine === "") {
|
||||||
processedLines.push(line);
|
processedLines.push(line);
|
||||||
@ -719,7 +746,13 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
setHasSelection(false);
|
setHasSelection(false);
|
||||||
setActiveFormats({ bold: false, italic: false, strikethrough: false });
|
setActiveFormats({ bold: false, italic: false, strikethrough: false });
|
||||||
}
|
}
|
||||||
}, [localPreviewMode, editContent, getCursorPosition, getActiveFormats, floatingToolbarEnabled]);
|
}, [
|
||||||
|
localPreviewMode,
|
||||||
|
editContent,
|
||||||
|
getCursorPosition,
|
||||||
|
getActiveFormats,
|
||||||
|
floatingToolbarEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleImageButtonClick = () => {
|
const handleImageButtonClick = () => {
|
||||||
imageInputRef.current?.click();
|
imageInputRef.current?.click();
|
||||||
@ -1083,7 +1116,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (note.updated_at && note.created_at !== note.updated_at) {
|
if (note.updated_at && note.created_at !== note.updated_at) {
|
||||||
const showEditDate = user?.show_edit_date !== undefined ? user.show_edit_date === 1 : true;
|
const showEditDate =
|
||||||
|
user?.show_edit_date !== undefined ? user.show_edit_date === 1 : true;
|
||||||
const updated = parseSQLiteUtc(note.updated_at);
|
const updated = parseSQLiteUtc(note.updated_at);
|
||||||
const updatedStr = formatLocalDateTime(updated);
|
const updatedStr = formatLocalDateTime(updated);
|
||||||
// Убеждаемся, что строка не содержит лишних символов
|
// Убеждаемся, что строка не содержит лишних символов
|
||||||
@ -1091,7 +1125,7 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
/(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})\d*.*/,
|
/(\d{2}\.\d{2}\.\d{4} \d{2}:\d{2})\d*.*/,
|
||||||
"$1"
|
"$1"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (showEditDate) {
|
if (showEditDate) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1228,13 +1262,16 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
Закреплено
|
Закреплено
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{note.syncStatus === 'pending' && (
|
{note.syncStatus === "pending" && (
|
||||||
<span className="sync-indicator" title="Ожидает синхронизации">
|
<span className="sync-indicator" title="Ожидает синхронизации">
|
||||||
<Icon icon="mdi:cloud-upload" />
|
<Icon icon="mdi:cloud-upload" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{note.syncStatus === 'error' && (
|
{note.syncStatus === "error" && (
|
||||||
<span className="sync-error-indicator" title="Ошибка синхронизации">
|
<span
|
||||||
|
className="sync-error-indicator"
|
||||||
|
title="Ошибка синхронизации"
|
||||||
|
>
|
||||||
<Icon icon="mdi:cloud-alert" />
|
<Icon icon="mdi:cloud-alert" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -1507,6 +1544,42 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
<ImageUpload images={editImages} onChange={setEditImages} />
|
<ImageUpload images={editImages} onChange={setEditImages} />
|
||||||
<FileUpload files={editFiles} onChange={setEditFiles} />
|
<FileUpload files={editFiles} onChange={setEditFiles} />
|
||||||
|
|
||||||
|
{user?.is_public_profile === 1 && (
|
||||||
|
<div
|
||||||
|
className="privacy-toggle-container"
|
||||||
|
style={{
|
||||||
|
marginBottom: "10px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editIsPrivate}
|
||||||
|
onChange={(e) => setEditIsPrivate(e.target.checked)}
|
||||||
|
style={{ width: "18px", height: "18px", cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "var(--text-primary, #333)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Приватная заметка
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="save-button-container">
|
<div className="save-button-container">
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
{aiEnabled && (
|
{aiEnabled && (
|
||||||
@ -1607,7 +1680,11 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
|||||||
{note.files && note.files.length > 0 && (
|
{note.files && note.files.length > 0 && (
|
||||||
<div className="note-files-container">
|
<div className="note-files-container">
|
||||||
{note.files.map((file) => {
|
{note.files.map((file) => {
|
||||||
const fileUrl = getFileUrl(file.file_path, Number(note.id), Number(file.id));
|
const fileUrl = getFileUrl(
|
||||||
|
file.file_path,
|
||||||
|
Number(note.id),
|
||||||
|
Number(file.id)
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div key={file.id} className="note-file-item">
|
<div key={file.id} className="note-file-item">
|
||||||
<a
|
<a
|
||||||
|
|||||||
181
src/components/notes/PublicNoteItem.tsx
Normal file
181
src/components/notes/PublicNoteItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@ -38,6 +38,10 @@ const ProfilePage: React.FC = () => {
|
|||||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||||
const [isLoading2FA, setIsLoading2FA] = useState(false);
|
const [isLoading2FA, setIsLoading2FA] = useState(false);
|
||||||
|
|
||||||
|
// Public profile settings
|
||||||
|
const [isPublicProfile, setIsPublicProfile] = useState(false);
|
||||||
|
const [publicProfileLink, setPublicProfileLink] = useState("");
|
||||||
|
|
||||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -82,6 +86,15 @@ const ProfilePage: React.FC = () => {
|
|||||||
setHasAvatar(false);
|
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 настройки
|
// Загружаем AI настройки
|
||||||
try {
|
try {
|
||||||
const aiSettings = await userApi.getAiSettings();
|
const aiSettings = await userApi.getAiSettings();
|
||||||
@ -258,6 +271,38 @@ const ProfilePage: React.FC = () => {
|
|||||||
return re.test(email);
|
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 (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="notes-header">
|
<header className="notes-header">
|
||||||
@ -418,6 +463,91 @@ const ProfilePage: React.FC = () => {
|
|||||||
|
|
||||||
<hr className="separator" />
|
<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>
|
<h3>Безопасность</h3>
|
||||||
{isLoading2FA ? (
|
{isLoading2FA ? (
|
||||||
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
|
<p style={{ textAlign: "center", color: "#999" }}>Загрузка...</p>
|
||||||
|
|||||||
217
src/pages/PublicProfilePage.tsx
Normal file
217
src/pages/PublicProfilePage.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const PublicProfilePage: React.FC = () => {
|
||||||
|
const { username } = useParams<{ username: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
@ -390,6 +390,8 @@ const SettingsPage: React.FC = () => {
|
|||||||
note_delete_permanent: "Окончательное удаление",
|
note_delete_permanent: "Окончательное удаление",
|
||||||
profile_update: "Обновление профиля",
|
profile_update: "Обновление профиля",
|
||||||
ai_improve: "Улучшение через AI",
|
ai_improve: "Улучшение через AI",
|
||||||
|
ai_generate_tags: "Генерация тегов через AI",
|
||||||
|
ai_merge: "Объединение заметок через AI",
|
||||||
};
|
};
|
||||||
return actionTypes[actionType] || actionType;
|
return actionTypes[actionType] || actionType;
|
||||||
};
|
};
|
||||||
@ -943,6 +945,8 @@ const SettingsPage: React.FC = () => {
|
|||||||
</option>
|
</option>
|
||||||
<option value="profile_update">Обновление профиля</option>
|
<option value="profile_update">Обновление профиля</option>
|
||||||
<option value="ai_improve">Улучшение через AI</option>
|
<option value="ai_improve">Улучшение через AI</option>
|
||||||
|
<option value="ai_generate_tags">Генерация тегов через AI</option>
|
||||||
|
<option value="ai_merge">Объединение заметок через AI</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="btnSave" onClick={() => loadLogs(true)}>
|
<button className="btnSave" onClick={() => loadLogs(true)}>
|
||||||
<Icon icon="mdi:refresh" /> Обновить
|
<Icon icon="mdi:refresh" /> Обновить
|
||||||
|
|||||||
@ -248,6 +248,7 @@ class SyncService {
|
|||||||
content: note.content,
|
content: note.content,
|
||||||
date: note.date,
|
date: note.date,
|
||||||
time: note.time,
|
time: note.time,
|
||||||
|
is_private: note.is_private !== undefined ? note.is_private : 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Сохраняем маппинг temp ID -> server ID
|
// Сохраняем маппинг temp ID -> server ID
|
||||||
@ -309,6 +310,7 @@ class SyncService {
|
|||||||
await axiosClient.put(`/notes/${item.noteId}`, {
|
await axiosClient.put(`/notes/${item.noteId}`, {
|
||||||
content: note.content,
|
content: note.content,
|
||||||
skipTimestamp: item.data.skipTimestamp,
|
skipTimestamp: item.data.skipTimestamp,
|
||||||
|
is_private: item.data.is_private !== undefined ? item.data.is_private : note.is_private,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1524,6 +1524,12 @@ textarea:focus {
|
|||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для disabled чекбоксов (в публичных профилях) */
|
||||||
|
.textNote input[type="checkbox"]:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Стили для элементов списка с чекбоксами (task-list-item создается marked.js) */
|
/* Стили для элементов списка с чекбоксами (task-list-item создается marked.js) */
|
||||||
.textNote .task-list-item {
|
.textNote .task-list-item {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
@ -4920,6 +4926,16 @@ textarea:focus {
|
|||||||
color: #0c5460;
|
color: #0c5460;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-action-ai_generate_tags {
|
||||||
|
background: #e7d5f5;
|
||||||
|
color: #6a1b9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-action-ai_merge {
|
||||||
|
background: #fff4e6;
|
||||||
|
color: #b8860b;
|
||||||
|
}
|
||||||
|
|
||||||
/* Стили для скрытого текста (спойлеров) */
|
/* Стили для скрытого текста (спойлеров) */
|
||||||
.spoiler {
|
.spoiler {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export interface Note {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
is_pinned: 0 | 1;
|
is_pinned: 0 | 1;
|
||||||
is_archived: 0 | 1;
|
is_archived: 0 | 1;
|
||||||
|
is_private?: 0 | 1;
|
||||||
pinned_at?: string;
|
pinned_at?: string;
|
||||||
images: NoteImage[];
|
images: NoteImage[];
|
||||||
files: NoteFile[];
|
files: NoteFile[];
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export interface User {
|
|||||||
colored_icons?: number;
|
colored_icons?: number;
|
||||||
floating_toolbar_enabled?: number;
|
floating_toolbar_enabled?: number;
|
||||||
two_factor_enabled?: number;
|
two_factor_enabled?: number;
|
||||||
|
is_public_profile?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@ -31,9 +31,13 @@ const spoilerExtension = {
|
|||||||
function renderTokens(tokens: any[], renderer: any): string {
|
function renderTokens(tokens: any[], renderer: any): string {
|
||||||
return tokens
|
return tokens
|
||||||
.map((token) => {
|
.map((token) => {
|
||||||
// Используем кастомный renderer если он есть
|
// Используем кастомный renderer если он есть и является функцией
|
||||||
if (renderer[token.type]) {
|
if (renderer[token.type] && typeof renderer[token.type] === 'function') {
|
||||||
return renderer[token.type](token);
|
try {
|
||||||
|
return renderer[token.type](token);
|
||||||
|
} catch (e) {
|
||||||
|
// Если метод не может обработать токен, используем fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fallback для встроенных типов токенов
|
// Fallback для встроенных типов токенов
|
||||||
if (token.type === "text") {
|
if (token.type === "text") {
|
||||||
@ -53,8 +57,12 @@ function renderTokens(tokens: any[], renderer: any): string {
|
|||||||
}
|
}
|
||||||
if (token.type === "link") {
|
if (token.type === "link") {
|
||||||
// Для ссылок используем кастомный renderer если доступен
|
// Для ссылок используем кастомный renderer если доступен
|
||||||
if (renderer.link) {
|
if (renderer.link && typeof renderer.link === 'function') {
|
||||||
return renderer.link(token);
|
try {
|
||||||
|
return renderer.link(token);
|
||||||
|
} catch (e) {
|
||||||
|
// Если метод не может обработать токен, используем fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fallback для встроенных ссылок
|
// Fallback для встроенных ссылок
|
||||||
const href = token.href || "";
|
const href = token.href || "";
|
||||||
@ -67,8 +75,12 @@ function renderTokens(tokens: any[], renderer: any): string {
|
|||||||
}
|
}
|
||||||
if (token.type === "spoiler") {
|
if (token.type === "spoiler") {
|
||||||
// Для спойлеров используем кастомный renderer если доступен
|
// Для спойлеров используем кастомный renderer если доступен
|
||||||
if (renderer.spoiler) {
|
if (renderer.spoiler && typeof renderer.spoiler === 'function') {
|
||||||
return renderer.spoiler(token);
|
try {
|
||||||
|
return renderer.spoiler(token);
|
||||||
|
} catch (e) {
|
||||||
|
// Если метод не может обработать токен, используем fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return `<span class="spoiler" title="Нажмите, чтобы показать">${
|
return `<span class="spoiler" title="Нажмите, чтобы показать">${
|
||||||
token.text || ""
|
token.text || ""
|
||||||
@ -145,14 +157,19 @@ const highlightCode = (code: string, lang?: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Кастомный renderer для внешних ссылок, чекбоксов и блоков кода
|
// Кастомный renderer для внешних ссылок, чекбоксов и блоков кода
|
||||||
const renderer: any = {
|
const createRenderer = (isReadOnly: boolean = false): any => {
|
||||||
link(token: any) {
|
// Создаем новый renderer, расширяя стандартный
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
|
||||||
|
// Переопределяем метод link для обработки внешних ссылок
|
||||||
|
renderer.link = function(token: any) {
|
||||||
const href = token.href;
|
const href = token.href;
|
||||||
const title = token.title;
|
const title = token.title;
|
||||||
|
|
||||||
// Правильно обрабатываем вложенные токены для форматирования внутри ссылок
|
// Используем стандартный метод для рендеринга текста ссылки
|
||||||
let text = "";
|
let text = "";
|
||||||
if (token.tokens && token.tokens.length > 0) {
|
if (token.tokens && token.tokens.length > 0) {
|
||||||
|
// Используем стандартные методы marked для рендеринга вложенных токенов
|
||||||
text = renderTokens(token.tokens, this);
|
text = renderTokens(token.tokens, this);
|
||||||
} else if (token.text) {
|
} else if (token.text) {
|
||||||
text = token.text;
|
text = token.text;
|
||||||
@ -170,16 +187,18 @@ const renderer: any = {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
|
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 task = token.task;
|
||||||
const checked = token.checked;
|
const checked = token.checked;
|
||||||
|
|
||||||
// Правильно обрабатываем вложенные токены для форматирования
|
// Правильно обрабатываем вложенные токены для форматирования
|
||||||
let content = "";
|
let content = "";
|
||||||
if (token.tokens && token.tokens.length > 0) {
|
if (token.tokens && token.tokens.length > 0) {
|
||||||
// Рендерим вложенные токены используя наш renderer
|
// Используем стандартные методы marked для рендеринга вложенных токенов
|
||||||
content = renderTokens(token.tokens, this);
|
content = renderTokens(token.tokens, this);
|
||||||
} else if (token.text) {
|
} else if (token.text) {
|
||||||
// Если токенов нет, используем текст (для обратной совместимости)
|
// Если токенов нет, используем текст (для обратной совместимости)
|
||||||
@ -187,13 +206,16 @@ const renderer: any = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (task) {
|
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 class="task-list-item">${checkbox} ${content}</li>\n`;
|
||||||
}
|
}
|
||||||
return `<li>${content}</li>\n`;
|
// Для обычных элементов списка используем стандартный метод
|
||||||
},
|
return originalListitem(token);
|
||||||
// Кастомный renderer для блоков кода с подсветкой синтаксиса
|
};
|
||||||
code(token: any) {
|
|
||||||
|
// Переопределяем метод code для подсветки синтаксиса
|
||||||
|
renderer.code = function(token: any) {
|
||||||
const code = token.text || "";
|
const code = token.text || "";
|
||||||
// В marked токен блока кода имеет поле lang
|
// В marked токен блока кода имеет поле lang
|
||||||
const lang = (token.lang || token.language || "").trim();
|
const lang = (token.lang || token.language || "").trim();
|
||||||
@ -205,19 +227,23 @@ const renderer: any = {
|
|||||||
const langLabel = lang ? `<span class="code-language">${lang}</span>` : "";
|
const langLabel = lang ? `<span class="code-language">${lang}</span>` : "";
|
||||||
|
|
||||||
return `<pre class="code-block">${langLabel}<code class="hljs ${langClass}">${highlightedCode}</code></pre>`;
|
return `<pre class="code-block">${langLabel}<code class="hljs ${langClass}">${highlightedCode}</code></pre>`;
|
||||||
},
|
};
|
||||||
|
|
||||||
|
return renderer;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Настройка marked
|
// Настройка marked (базовая конфигурация)
|
||||||
marked.use({
|
marked.use({
|
||||||
extensions: [spoilerExtension],
|
extensions: [spoilerExtension],
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
renderer,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const parseMarkdown = (text: string): string => {
|
export const parseMarkdown = (text: string, isReadOnly: boolean = false): string => {
|
||||||
return marked.parse(text) as string;
|
const renderer = createRenderer(isReadOnly);
|
||||||
|
// Используем marked.parse с renderer
|
||||||
|
const result = marked.parse(text, { renderer });
|
||||||
|
return result as string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для извлечения тегов из текста
|
// Функция для извлечения тегов из текста
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user