Initial QOffice implementation
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
import { Archive, Download, FileText, FileUp, Flag, MailOpen, Paperclip, PenLine, Search, Send, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { MailAttachment, MailMessage, QOfficeState } from "../../types";
|
||||
import { downloadBlobFile } from "../../utils/download";
|
||||
import { attachmentToBlob, createMailAttachmentFromFile, exportEmlBlob, importMailFile } from "../../io/outlookMail";
|
||||
import { replaceFileExtension } from "../../io/fileHelpers";
|
||||
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
|
||||
import { exportPdfOrPrint } from "../../io/pdfExport";
|
||||
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
|
||||
|
||||
interface QOutlookProps {
|
||||
mail: QOfficeState["mail"];
|
||||
onChange: (updater: (mail: QOfficeState["mail"]) => QOfficeState["mail"]) => void;
|
||||
onFolderChange: (folder: MailMessage["folder"]) => void;
|
||||
}
|
||||
|
||||
const folders: Array<{ id: MailMessage["folder"]; label: string }> = [
|
||||
{ id: "inbox", label: "Входящие" },
|
||||
{ id: "sent", label: "Отправленные" },
|
||||
{ id: "drafts", label: "Черновики" },
|
||||
{ id: "archive", label: "Архив" }
|
||||
];
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 Б";
|
||||
}
|
||||
|
||||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function QOutlook({ mail, onChange, onFolderChange }: QOutlookProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const draftAttachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [draft, setDraft] = useState<{ to: string; subject: string; body: string; attachments: MailAttachment[] }>({
|
||||
to: "",
|
||||
subject: "",
|
||||
body: "",
|
||||
attachments: []
|
||||
});
|
||||
const activeMessage = mail.messages.find((message) => message.id === mail.activeMessageId) ?? mail.messages[0];
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return mail.messages.filter((message) => {
|
||||
const inFolder = message.folder === mail.activeFolder;
|
||||
const matchesQuery =
|
||||
!normalizedQuery ||
|
||||
[message.from, message.to, message.subject, message.body, ...(message.attachments ?? []).map((attachment) => attachment.fileName)].some((field) =>
|
||||
field.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
return inFolder && matchesQuery;
|
||||
});
|
||||
}, [mail.activeFolder, mail.messages, query]);
|
||||
|
||||
function updateMessage(id: string, patch: Partial<MailMessage>) {
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
messages: current.messages.map((message) => (message.id === id ? { ...message, ...patch } : message))
|
||||
}));
|
||||
}
|
||||
|
||||
function sendDraft() {
|
||||
if (!draft.to.trim() || !draft.subject.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `mail-${Date.now()}`;
|
||||
const message: MailMessage = {
|
||||
id,
|
||||
folder: "sent",
|
||||
from: "product@qoffice.local",
|
||||
to: draft.to.trim(),
|
||||
subject: draft.subject.trim(),
|
||||
body: draft.body.trim(),
|
||||
time: new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }),
|
||||
read: true,
|
||||
flagged: false,
|
||||
attachments: draft.attachments
|
||||
};
|
||||
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
activeFolder: "sent",
|
||||
activeMessageId: id,
|
||||
messages: [message, ...current.messages]
|
||||
}));
|
||||
setDraft({ to: "", subject: "", body: "", attachments: [] });
|
||||
}
|
||||
|
||||
async function openMailFile(file: File | undefined) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imported = await importMailFile(file);
|
||||
onChange((current) => ({
|
||||
...current,
|
||||
activeFolder: "inbox",
|
||||
activeMessageId: imported.id,
|
||||
messages: [imported, ...current.messages]
|
||||
}));
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "Не удалось открыть письмо.");
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEml(message: MailMessage) {
|
||||
try {
|
||||
const blob = exportEmlBlob(message);
|
||||
const fileName = replaceFileExtension(message.subject || "Письмо", ".eml");
|
||||
if (isDesktopOfficeBridgeAvailable()) {
|
||||
await saveDesktopOfficeFile("eml", fileName, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlobFile(fileName, blob);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "Не удалось сохранить EML.");
|
||||
}
|
||||
}
|
||||
|
||||
async function addDraftAttachments(files: FileList | null) {
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(Array.from(files).map((file) => createMailAttachmentFromFile(file)));
|
||||
setDraft((current) => ({ ...current, attachments: [...current.attachments, ...attachments] }));
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "Не удалось добавить вложение.");
|
||||
} finally {
|
||||
if (draftAttachmentInputRef.current) {
|
||||
draftAttachmentInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeDraftAttachment(id: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
attachments: current.attachments.filter((attachment) => attachment.id !== id)
|
||||
}));
|
||||
}
|
||||
|
||||
function downloadAttachment(attachment: MailAttachment) {
|
||||
downloadBlobFile(attachment.fileName, attachmentToBlob(attachment));
|
||||
}
|
||||
|
||||
async function chooseMailFile() {
|
||||
if (isDesktopOfficeBridgeAvailable()) {
|
||||
const file = await openDesktopOfficeFile("mail");
|
||||
await openMailFile(file ?? undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
async function exportPdf() {
|
||||
try {
|
||||
await exportPdfOrPrint(activeMessage?.subject || "Письмо QOutlook");
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
|
||||
}
|
||||
}
|
||||
|
||||
useQOfficeCommand({
|
||||
open: chooseMailFile,
|
||||
save: () => {
|
||||
if (activeMessage) {
|
||||
void saveEml(activeMessage);
|
||||
}
|
||||
},
|
||||
"export-pdf": exportPdf,
|
||||
print: () => window.print()
|
||||
});
|
||||
|
||||
const activeAttachments = activeMessage?.attachments ?? [];
|
||||
|
||||
return (
|
||||
<div className="mail-layout">
|
||||
<aside className="mail-folders" aria-label="Папки почты">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="hidden-file-input"
|
||||
type="file"
|
||||
accept=".eml,.msg,message/rfc822,application/vnd.ms-outlook"
|
||||
onChange={(event) => void openMailFile(event.target.files?.[0])}
|
||||
aria-label="Открыть EML или MSG"
|
||||
/>
|
||||
<button className="wide-command compose-command" type="button">
|
||||
<PenLine size={16} />
|
||||
Написать
|
||||
</button>
|
||||
<button className="wide-command" type="button" onClick={() => void chooseMailFile()}>
|
||||
<FileUp size={16} />
|
||||
Открыть EML/MSG
|
||||
</button>
|
||||
{folders.map((folder) => {
|
||||
const count = mail.messages.filter((message) => message.folder === folder.id).length;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={folder.id}
|
||||
className={mail.activeFolder === folder.id ? "is-active" : ""}
|
||||
onClick={() => onFolderChange(folder.id)}
|
||||
>
|
||||
<span>{folder.label}</span>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
|
||||
<section className="message-list-panel" aria-label="Список писем">
|
||||
<label className="mail-search">
|
||||
<Search size={16} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Поиск писем" />
|
||||
</label>
|
||||
<div className="message-list">
|
||||
{visibleMessages.map((message) => {
|
||||
const attachmentCount = message.attachments?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={message.id}
|
||||
className={`message-row ${message.id === activeMessage?.id ? "is-active" : ""} ${message.read ? "" : "is-unread"}`}
|
||||
onClick={() => {
|
||||
onChange((current) => ({ ...current, activeMessageId: message.id }));
|
||||
updateMessage(message.id, { read: true });
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{message.from}</strong>
|
||||
<small>{message.time}</small>
|
||||
</span>
|
||||
<b>{message.subject}</b>
|
||||
<em>{message.body}</em>
|
||||
{attachmentCount > 0 ? (
|
||||
<small className="message-attachment-count">
|
||||
<Paperclip size={13} />
|
||||
{attachmentCount}
|
||||
</small>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{visibleMessages.length === 0 ? <p className="empty-state">В этой папке нет писем.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="message-reader" aria-label="Чтение письма">
|
||||
{activeMessage ? (
|
||||
<>
|
||||
<div className="message-actions">
|
||||
<button type="button" onClick={() => updateMessage(activeMessage.id, { read: !activeMessage.read })}>
|
||||
<MailOpen size={16} />
|
||||
{activeMessage.read ? "Отметить непрочитанным" : "Отметить прочитанным"}
|
||||
</button>
|
||||
<button type="button" onClick={() => updateMessage(activeMessage.id, { flagged: !activeMessage.flagged })}>
|
||||
<Flag size={16} />
|
||||
{activeMessage.flagged ? "Снять флаг" : "Поставить флаг"}
|
||||
</button>
|
||||
<button type="button" onClick={() => updateMessage(activeMessage.id, { folder: "archive" })}>
|
||||
<Archive size={16} />
|
||||
В архив
|
||||
</button>
|
||||
<button type="button" onClick={() => void saveEml(activeMessage)}>
|
||||
<Download size={16} />
|
||||
Сохранить EML
|
||||
</button>
|
||||
<button type="button" onClick={() => void exportPdf()}>
|
||||
<FileText size={16} />
|
||||
Экспорт PDF
|
||||
</button>
|
||||
</div>
|
||||
<article className="message-content">
|
||||
<h2>{activeMessage.subject}</h2>
|
||||
<p className="muted">
|
||||
От: {activeMessage.from} · Кому: {activeMessage.to} · {activeMessage.time}
|
||||
</p>
|
||||
<p>{activeMessage.body}</p>
|
||||
{activeAttachments.length > 0 ? (
|
||||
<section className="attachments-panel" aria-label="Вложения письма">
|
||||
<h3>Вложения</h3>
|
||||
<ul className="attachment-list">
|
||||
{activeAttachments.map((attachment) => (
|
||||
<li key={attachment.id} className="attachment-item">
|
||||
<span className="attachment-main">
|
||||
<Paperclip size={16} />
|
||||
<span>
|
||||
<strong>{attachment.fileName}</strong>
|
||||
<small>
|
||||
{attachment.contentType} · {formatBytes(attachment.size)}
|
||||
</small>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" onClick={() => downloadAttachment(attachment)} aria-label={`Скачать ${attachment.fileName}`}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</article>
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">Выберите письмо.</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="compose-panel"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
sendDraft();
|
||||
}}
|
||||
>
|
||||
<h3>Новое письмо</h3>
|
||||
<input value={draft.to} onChange={(event) => setDraft({ ...draft, to: event.target.value })} placeholder="Кому" aria-label="Кому" />
|
||||
<input
|
||||
value={draft.subject}
|
||||
onChange={(event) => setDraft({ ...draft, subject: event.target.value })}
|
||||
placeholder="Тема"
|
||||
aria-label="Тема"
|
||||
/>
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => setDraft({ ...draft, body: event.target.value })}
|
||||
placeholder="Текст письма"
|
||||
aria-label="Текст письма"
|
||||
/>
|
||||
<input
|
||||
ref={draftAttachmentInputRef}
|
||||
className="hidden-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(event) => void addDraftAttachments(event.target.files)}
|
||||
aria-label="Добавить вложения"
|
||||
/>
|
||||
<div className="compose-attachments">
|
||||
<button type="button" onClick={() => draftAttachmentInputRef.current?.click()}>
|
||||
<Paperclip size={16} />
|
||||
Добавить файлы
|
||||
</button>
|
||||
{draft.attachments.length > 0 ? (
|
||||
<ul className="attachment-list compact">
|
||||
{draft.attachments.map((attachment) => (
|
||||
<li key={attachment.id} className="attachment-item">
|
||||
<span className="attachment-main">
|
||||
<Paperclip size={16} />
|
||||
<span>
|
||||
<strong>{attachment.fileName}</strong>
|
||||
<small>{formatBytes(attachment.size)}</small>
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" onClick={() => removeDraftAttachment(attachment.id)} aria-label={`Удалить ${attachment.fileName}`}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<button className="primary-command" type="submit" disabled={!draft.to.trim() || !draft.subject.trim()}>
|
||||
<Send size={16} />
|
||||
Отправить
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user