Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+390
View File
@@ -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>
);
}