diff --git a/frontend/src/api/labelsApi.js b/frontend/src/api/labelsApi.js new file mode 100644 index 00000000..c38528a8 --- /dev/null +++ b/frontend/src/api/labelsApi.js @@ -0,0 +1,90 @@ +// src/api/labelsApi.js + +// Base URL — adjust port/host if needed +const API_BASE = "http://localhost:3001/api"; +const ROOT = `${API_BASE}/labels`; + +/** + * Fetch all labels + * @returns {Promise>} + */ +export async function fetchLabels() { + const token = localStorage.getItem("token"); + const res = await fetch(ROOT, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + if (!res.ok) throw new Error(`fetchLabels failed: ${res.status}`); + return res.json(); +} + +/** + * Create a new label + * @param {string} name + */ +export async function createLabel(name) { + const token = localStorage.getItem("token"); + const res = await fetch(ROOT, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name }) + }); + if (!res.ok) throw new Error(`createLabel failed: ${res.status}`); +} + +/** + * Rename an existing label + * @param {number} id + * @param {string} newName + */ +export async function editLabel(id, newName) { + const token = localStorage.getItem("token"); + const res = await fetch(`${ROOT}/${id}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name: newName }) + }); + if (!res.ok) throw new Error(`editLabel failed: ${res.status}`); +} + +/** + * Delete a label + * @param {number} id + */ +export async function deleteLabel(id) { + const token = localStorage.getItem("token"); + const res = await fetch(`${ROOT}/${id}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${token}` + } + }); + if (!res.ok) throw new Error(`deleteLabel failed: ${res.status}`); +} + +/** + * Create a sublabel under a parent label + * @param {number} parentId + * @param {string} name + */ +export async function createLabelUnderParent(parentId, name) { + const token = localStorage.getItem("token"); + const res = await fetch(`${ROOT}/${parentId}/sublabel`, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ name }) + }); + if (!res.ok) throw new Error(`createLabelUnderParent failed: ${res.status}`); + return res.json(); +} diff --git a/frontend/src/api/mailApi.js b/frontend/src/api/mailApi.js index 05735688..81c88a6e 100644 --- a/frontend/src/api/mailApi.js +++ b/frontend/src/api/mailApi.js @@ -63,9 +63,11 @@ export async function deleteMail(mail) { headers: { 'Authorization': `Bearer ${token}`, } - }) - if (!res.ok) - throw new Error(`getMails failed: ${res.status}`); + }); + + if (!res.ok) { + throw new Error(`deleteMail failed: ${res.status}`); + } } /** @@ -180,4 +182,106 @@ export const searchMails = async (userId, query) => { if (!res.ok) throw new Error(`Search failed ${res.status}`); return res.json(); -}; \ No newline at end of file +} + +/** + * Send a new mail or save as draft. + * @param { { + * subject: string, + * body: string, + * sentTo: String[], + * saveAsDraft?: boolean, + * files: Object[] + * } } mailData + * - subject: email subject + * - body: HTML or plain text body + * - sentTo: list of recipient email addresses + * - saveAsDraft: true to save in Drafts, false to actually send + */ +export async function sendMail({subject, body, sentTo, saveAsDraft = false, files = []}) { + const url = `${API_BASE}/mails`; + const token = localStorage.getItem("token"); + + // Comments: what we send in the request body + // subject: the mail's subject line + // body: the mail's content (HTML) + // sentTo: array of recipient emails + // saveAsDraft: boolean flag—true = save to Draft folder + const payload = { + subject, + body, + sentTo, + saveAsDraft, + files + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + throw new Error(`sendMail failed: ${res.status}`); + } + + return res.json(); +} + +/** + * PATCH /api/mails/:id + * @param {number} mailId + * @param {{ + * subject?: string, + * body?: string, + * sentTo?: string[], + * saveAsDraft?: boolean + * }} data + */ +export async function updateMail(mailId, { + subject, + body, + sentTo, + saveAsDraft = true +}) { + const url = `${API_BASE}/mails/${mailId}`; + const token = localStorage.getItem("token"); + + const res = await fetch(url, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({subject, body, sentTo, saveAsDraft}) + }); + + if (!res.ok) throw new Error(`updateMail failed: ${res.status}`); + return res.json(); +} + +/** + * Fetch mails under a specific label. + * + * @param {Number} labelId label's id + * @param {number} page + * @returns {Promise<{ mails: any[], total: number }>} + */ +export async function getMailsByLabel(labelId, page = 1) { + const token = localStorage.getItem("token"); + // note: backend endpoint is the same, just pass label=... + const url = `${API_BASE}/mails?label=${encodeURIComponent(labelId)}&page=${page}&limit=50`; + const res = await fetch(url, { + method: "GET", + headers: { + "Authorization": `Bearer ${token}` + } + }); + if (!res.ok) { + throw new Error(`getMailsByLabel failed: ${res.status}`); + } + return res.json(); +} diff --git a/frontend/src/api/userApi.js b/frontend/src/api/userApi.js index 6ab9716a..bc360ce0 100644 --- a/frontend/src/api/userApi.js +++ b/frontend/src/api/userApi.js @@ -59,3 +59,25 @@ export async function loginWithJwt(mail, password) { return 500; } } +export async function searchUsers(query) { + try { + const token = localStorage.getItem("token"); + const res = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}`, { + headers: { + "Authorization": `Bearer ${token}` + } + }); + + if (!res.ok) { + console.error("Search failed:", await res.text()); + return []; + } + + return await res.json(); + } catch (error) { + console.error("Error searching users:", error); + return []; + } +} + + diff --git a/frontend/src/main_page/ComposeEmail/ComposeEmail.css b/frontend/src/main_page/ComposeEmail/ComposeEmail.css index 88e55099..539a43dc 100644 --- a/frontend/src/main_page/ComposeEmail/ComposeEmail.css +++ b/frontend/src/main_page/ComposeEmail/ComposeEmail.css @@ -1,10 +1,10 @@ -/* ComposeEmail.css */ - .compose-email { position: fixed; - bottom: 0; - right: 24px; - width: 580px; + bottom: 2vh; + right: 2vw; + width: 35vw; + max-width: 500px; + max-height: 75vh; background: #fff; border: 1px solid #dadce0; border-radius: 8px 8px 0 0; @@ -12,32 +12,54 @@ 0 2px 6px rgba(60,64,67,0.15); display: flex; flex-direction: column; - max-height: 80vh; font-family: 'Roboto', sans-serif; z-index: 1000; + color: #202124; /* default text color */ +} + +.compose-email.minimized .compose-body, +.compose-email.minimized .compose-footer { + display: none; +} + +.compose-email.maximized { + top: 10vh !important; + bottom: 10vh !important; + left: 10vw !important; + right: 10vw !important; + width: auto !important; + height: auto !important; + max-width: none !important; + max-height: none !important; + border-radius: 8px !important; } .compose-header { background: #f1f3f4; - padding: 8px 12px; + padding: 0.5em 1em; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dadce0; - border-radius: 8px 8px 0 0; } .compose-title { - font-size: 14px; + font-size: 1em; font-weight: 500; color: #202124; } +/* --- Only this part was added for dark mode title --- */ +.compose-email.dark .compose-title { + color: #f1f3f4 !important; +} +/* ---------------------------------------- */ + .compose-controls .control-btn { background: transparent; border: none; - margin-left: 4px; - padding: 4px; + margin-left: 0.5em; + padding: 0.4em; cursor: pointer; color: #5f6368; border-radius: 4px; @@ -49,30 +71,73 @@ } .compose-body { - padding: 8px 12px; + padding: 0.5em 1em; overflow-y: auto; + flex: 1; } .compose-field { display: flex; align-items: center; - margin-bottom: 8px; + margin-bottom: 0.5em; + position: relative; +} + +/* Recipients chips */ +.recipient-input-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + flex: 1; +} + +.recipient-chip { + background: #e8f0fe; + border: 1px solid #aecbfa; + border-radius: 12px; + padding: 2px 8px; + display: flex; + align-items: center; + font-size: 0.9em; + color: #202124; +} + +.recipient-chip button { + background: transparent; + border: none; + margin-left: 4px; + cursor: pointer; + font-size: 1em; + color: #5f6368; +} + +.to-input { + flex: 1; + min-width: 120px; + padding: 0.4em; + border: none; + outline: none; + color: inherit; } .field-label { - width: 64px; - font-size: 13px; + width: 10%; + min-width: 4em; + font-size: 0.9em; color: #5f6368; } .field-input { flex: 1; - padding: 6px 8px; + padding: 0.5em; border: none; border-bottom: 1px solid #dadce0; - font-size: 14px; + font-size: 1em; outline: none; transition: border-color 0.2s; + color: inherit; + background: transparent; } .field-input:focus { @@ -81,48 +146,130 @@ .compose-toolbar { display: flex; - gap: 12px; - padding: 4px 0 8px; + gap: 0.75em; + padding: 0.5em 0; + font-size: 1em; +} + +.compose-toolbar button, +.compose-toolbar select { + background: transparent; + border: none; color: #5f6368; - font-size: 16px; + cursor: pointer; + padding: 0.4em; + font-size: 1em; + transition: color 0.2s, background 0.2s; } -.compose-toolbar i:hover { +.compose-toolbar button:hover, +.compose-toolbar select:hover { color: #202124; - cursor: pointer; + background: rgba(95,99,104,0.08); + border-radius: 4px; +} + +.font-size-select { + appearance: none; } -.compose-textarea { +.compose-editor { width: 100%; + min-height: 40vh; + padding: 0.75em; border: none; + border-bottom: 1px solid #dadce0; outline: none; - resize: none; - font-size: 14px; + font-size: 1em; line-height: 1.4; - min-height: 200px; + transition: border-bottom-color 0.2s; + direction: ltr; + text-align: left; + color: inherit; + background: transparent; } +.compose-editor:focus { + border-bottom-color: #1a73e8; +} + + +.attachment-item { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + font-size: 0.9em; + color: #202124; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-item .bi-paperclip { + flex-shrink: 0; +} + +.attachment-item .file-name { + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-item .remove-btn { + background: transparent; + border: none; + color: #5f6368; + font-size: 1em; + cursor: pointer; + padding: 2px 6px; + margin-left: 4px; + transition: color 0.2s; +} + +.attachment-item .remove-btn:hover { + color: #d93025; +} + +/* Optional: dark mode tweaks */ +.compose-email.dark .attachment-item { + color: #e8eaed; +} +.compose-email.dark .remove-btn { + color: #9aa0a6; +} +.compose-email.dark .remove-btn:hover { + color: #f28b82; +} + + .compose-footer { - padding: 8px 12px; + padding: 0.5em 1em; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #dadce0; background: #f1f3f4; - border-radius: 0 0 8px 8px; } -.footer-left .attach-btn { +.footer-attach-btn { background: transparent; border: none; - font-size: 18px; + font-size: 1.2em; color: #5f6368; cursor: pointer; + padding: 0.4em; + transition: color 0.2s; +} + +.footer-attach-btn:hover { + color: #202124; } .footer-right { display: flex; - gap: 8px; + gap: 0.5em; } .send-btn { @@ -130,12 +277,12 @@ color: #fff; border: none; border-radius: 4px; - padding: 6px 20px; - font-size: 14px; + padding: 0.5em 1.5em; + font-size: 0.9em; font-weight: 500; display: flex; align-items: center; - gap: 4px; + gap: 0.4em; box-shadow: 0 1px 1px rgba(60,64,67,0.3); cursor: pointer; transition: background 0.2s; @@ -149,7 +296,7 @@ background: transparent; border: none; color: #5f6368; - font-size: 14px; + font-size: 0.9em; cursor: pointer; transition: color 0.2s; } @@ -157,3 +304,110 @@ .discard-btn:hover { color: #d93025; } + +.suggestions-list { + position: absolute; + top: 100%; + left: 10%; + width: 90%; + max-height: 30vh; + overflow-y: auto; + margin-top: 0.2em; + background: #fff; + border: 1px solid #dadce0; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(60,64,67,0.15); + z-index: 1100; + font-size: 0.9em; + list-style: none; + padding: 0; +} + +.suggestions-list li { + padding: 0.5em 0.75em; + cursor: pointer; + color: #202124; +} + +.suggestions-list li:hover { + background-color: #f1f3f4; +} + +/* Dark mode overrides */ +.compose-email.dark { + background: #202124; + border-color: #5f6368; + color: #e8eaed; +} + +.compose-email.dark .compose-header, +.compose-email.dark .compose-footer { + background: #171717; + border-color: #5f6368; +} + +.compose-email.dark .recipient-chip { + background: #3c4043; + border-color: #5f6368; + color: #e8eaed; +} + +.compose-email.dark .field-label { + color: #e8eaed; +} + +.compose-email.dark .field-input, +.compose-email.dark .to-input { + background: #303134; + color: #e8eaed; + border-bottom-color: #5f6368; +} + +.compose-email.dark .field-input::placeholder, +.compose-email.dark .to-input::placeholder { + color: #9aa0a6; +} + +.compose-email.dark .compose-editor { + background: #303134; + color: #e8eaed; + border-bottom-color: #5f6368; +} + +.compose-email.dark .compose-editor:focus { + border-bottom-color: #8ab4f8; +} + +.compose-email.dark .compose-toolbar button, +.compose-email.dark .font-size-select { + color: #e8eaed; +} + +.compose-email.dark .compose-toolbar button:hover { + background: rgba(232,234,237,0.1); +} + +.compose-email.dark .send-btn { + background-color: #8ab4f8; +} + +.compose-email.dark .send-btn:hover { + background-color: #669df6; +} + +.compose-email.dark .suggestions-list { + background: #303134; + border-color: #5f6368; +} + +.compose-email.dark .suggestions-list li { + color: #e8eaed; +} + +.compose-email.dark .suggestions-list li:hover { + background-color: #3c4043; +} + +.compose-email.dark .attachment-list li { + color: #e8eaed; +} diff --git a/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx b/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx index c54d33a0..3580456e 100644 --- a/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx +++ b/frontend/src/main_page/ComposeEmail/ComposeEmail.jsx @@ -1,92 +1,318 @@ -"use client"; - -import React, { useState } from 'react'; +'use client'; +import React, { useState, useEffect, useRef } from 'react'; +import { useTheme } from '../../utils/useTheme'; +import { sendMail, updateMail } from '../../api/mailApi'; +import { searchUsers } from '../../api/userApi'; import 'bootstrap-icons/font/bootstrap-icons.css'; import './ComposeEmail.css'; -export default function ComposeEmail({ onCancel, onSend }) { - const [to, setTo] = useState(''); +import { + execCommand, + handleFileAttachments +} from '../../utils/composeUtils'; + +/** + * ComposeEmail component: Handles sending or saving emails and drafts + * @param {function} onCancel - Called when closing or discarding the window + * @param {function} onSend - Called after successfully sending + * @param {number} offset - How far to offset the window (for stacking) + * @param {object|null} draftMail - If present, editing a draft + */ +export default function ComposeEmail({ + onCancel, + onSend, + offset = 0, + draftMail = null + }) { + const { theme } = useTheme(); + + // States for fields + const [toQuery, setToQuery] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [recipients, setRecipients] = useState([]); const [subject, setSubject] = useState(''); - const [body, setBody] = useState(''); + const [attachments, setAttachments] = useState([]); + const [view, setView] = useState('normal'); // minimized, maximized, normal + + // Store the current editor text in state so it survives minimize/maximize + const [editorHtml, setEditorHtml] = useState(''); + const editorRef = useRef(null); + const attachRef = useRef(null); + + /** + * On editing a draft, prefill all fields only when draftMail changes. + * And initialize the editor’s innerHTML here once + */ + useEffect(() => { + if (!draftMail) return; + + setRecipients(draftMail.sentTo.map(u => u.mail)); + setSubject(draftMail.subject || ''); + + // initialize the editor only once here + const initial = draftMail.body || ''; + setEditorHtml(initial); + if (editorRef.current) { + editorRef.current.innerHTML = initial; + } + + setAttachments(draftMail.attachments || []); + setToQuery(''); + setSuggestions([]); + }, [draftMail]); - const handleSend = () => { - onSend({ to, subject, body }); + + // Autocomplete for "To" field + useEffect(() => { + const timer = setTimeout(() => { + if (toQuery.length >= 1) { + searchUsers(toQuery).then(setSuggestions); + } else { + setSuggestions([]); + } + }, 300); + return () => clearTimeout(timer); + }, [toQuery]); + + // Add recipient if not already present + const addRecipient = email => { + const e = email.trim(); + if (e && !recipients.includes(e)) { + setRecipients(prev => [...prev, e]); + } + setToQuery(''); + setSuggestions([]); + }; + const removeRecipient = email => { + setRecipients(prev => prev.filter(e => e !== email)); + }; + const handleKeyDown = e => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addRecipient(toQuery); + } }; + /** + * Send or save as draft (uses state for editorHtml). + * If draftMail.id exists, update; otherwise create. + */ + const postMail = async saveAsDraft => { + if (!saveAsDraft && !recipients.length) { + alert('Add at least one recipient.'); + return; + } + const body = editorHtml; + if (draftMail?.id) { + await updateMail(draftMail.id, { + subject, + body, + sentTo: recipients, + saveAsDraft, + files: attachments + }); + } else { + await sendMail({ + subject, + body, + sentTo: recipients, + saveAsDraft, + files: attachments + }); + } + }; + + // Handlers for the toolbar and window controls + const handleSend = async () => { + await postMail(false); + onSend?.(); + }; + const handleClose = async () => { + // Only save draft if there is at least one recipient + if (recipients.length > 0) { + await postMail(true); + } + onCancel(); + }; + const handleDiscard = () => { + onCancel(); + }; + const toggleMinimize = () => + setView(v => (v === 'minimized' ? 'normal' : 'minimized')); + const toggleMaximize = () => + setView(v => (v === 'maximized' ? 'normal' : 'maximized')); + const rightOffset = `calc(2vw + ${offset * 36}vw)`; + return ( -
- {/* Header with title and controls */} +
- New Message + + {draftMail ? 'Edit Draft' : 'New Message'} +
- - -
- {/* Body with “To”, “Subject” and formatting toolbar */} -
-
- To - setTo(e.target.value)} - className="field-input" - placeholder="recipient@example.com" - /> -
-
- Subject - setSubject(e.target.value)} - className="field-input" - placeholder="Subject" - /> -
+ {view !== 'minimized' && ( + <> +
+ {/* To: field with recipient chips and autocomplete */} +
+ To +
+ {recipients.map(email => ( + + {email} + + + ))} + setToQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ {suggestions.length > 0 && ( +
    + {suggestions.map(u => ( +
  • addRecipient(u.mail)}> + {u.mail} +
  • + ))} +
+ )} +
-
- - - - - - -
+ {/* Subject input */} +
+ Subject + setSubject(e.target.value)} + /> +
-