182 lines
6.3 KiB
TypeScript
182 lines
6.3 KiB
TypeScript
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>
|
||
);
|
||
};
|
||
|