fix build env

This commit is contained in:
nkhangg 2025-05-06 18:50:07 +07:00
parent 42aeeb3e8d
commit 375725c7d7
15 changed files with 536 additions and 573 deletions

2
.env
View File

@ -1 +1 @@
API_KEY = '@yQveRWre@b(!_9HmL' VITE_API_KEY = '@yQveRWre@b(!_9HmL'

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_KEY = ''

1
.gitignore vendored
View File

@ -13,6 +13,7 @@ dist-electron
release release
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@ -1,43 +1,34 @@
// @see - https://www.electron.build/configuration/configuration // @see - https://www.electron.build/configuration/configuration
{ {
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", $schema: 'https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json',
"appId": "YourAppID", appId: 'b1730d2e-6f3c-4f92-9f4a-9f8e918b40d2',
"asar": true, asar: true,
"productName": "YourAppName", productName: 'Zulip messages',
"directories": { directories: {
"output": "release/${version}" output: 'release/${version}',
}, },
"files": [ files: ['dist', 'dist-electron'],
"dist", mac: {
"dist-electron" target: ['dmg'],
], artifactName: '${productName}-Mac-${version}-Installer.${ext}',
"mac": { },
"target": [ win: {
"dmg" target: [
], {
"artifactName": "${productName}-Mac-${version}-Installer.${ext}" target: 'nsis',
}, arch: ['x64'],
"win": { },
"target": [ ],
{ artifactName: '${productName}-Windows-${version}-Setup.${ext}',
"target": "nsis", },
"arch": [ nsis: {
"x64" oneClick: false,
] perMachine: false,
} allowToChangeInstallationDirectory: true,
], deleteAppDataOnUninstall: false,
"artifactName": "${productName}-Windows-${version}-Setup.${ext}" },
}, linux: {
"nsis": { target: ['AppImage'],
"oneClick": false, artifactName: '${productName}-Linux-${version}.${ext}',
"perMachine": false, },
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
}
} }

View File

@ -1,17 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { config } from "dotenv"; import { app, BrowserWindow, ipcMain, Menu, Notification, screen } from 'electron';
import { app, BrowserWindow, ipcMain, Notification, screen } from "electron"; import path from 'node:path';
import path from "node:path"; import { fileURLToPath } from 'node:url';
import { fileURLToPath } from "node:url"; import { io } from 'socket.io-client';
import { io } from "socket.io-client"; import { addEmail, deleteEmail, fetchEmails, fetchMessages } from '../src/apis';
import { WebSocket } from "ws"; import { createMailWindow } from './windows/mails.window';
import { addEmail, deleteEmail, fetchEmails, fetchMessages } from "../src/apis";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// dot env
config();
// The built directory structure // The built directory structure
// //
// ├─┬─┬ dist // ├─┬─┬ dist
@ -21,174 +17,142 @@ config();
// │ │ ├── main.js // │ │ ├── main.js
// │ │ └── preload.mjs // │ │ └── preload.mjs
// │ // │
process.env.APP_ROOT = path.join(__dirname, ".."); process.env.APP_ROOT = path.join(__dirname, '..');
let newWin: BrowserWindow | null = null;
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x // 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];
export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST;
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
let win: BrowserWindow | null; let win: BrowserWindow | null;
Menu.setApplicationMenu(null);
function createWindow() { function createWindow() {
// Lấy thông tin tất cả các màn hình // Lấy thông tin tất cả các màn hình
const displays = screen.getAllDisplays(); const displays = screen.getAllDisplays();
// Lấy vị trí con trỏ chuột // Lấy vị trí con trỏ chuột
const cursorPoint = screen.getCursorScreenPoint(); const cursorPoint = screen.getCursorScreenPoint();
// Tìm màn hình có chứa con trỏ chuột // Tìm màn hình có chứa con trỏ chuột
let display = displays.find((display) => { let display = displays.find((display) => {
const { x, y, width, height } = display.bounds; const { x, y, width, height } = display.bounds;
return ( return cursorPoint.x >= x && cursorPoint.x <= x + width && cursorPoint.y >= y && cursorPoint.y <= y + height;
cursorPoint.x >= x && });
cursorPoint.x <= x + width &&
cursorPoint.y >= y &&
cursorPoint.y <= y + height
);
});
// Nếu không tìm thấy màn hình chứa con trỏ, sử dụng màn hình chính // Nếu không tìm thấy màn hình chứa con trỏ, sử dụng màn hình chính
if (!display) { if (!display) {
display = screen.getPrimaryDisplay(); display = screen.getPrimaryDisplay();
} }
const { width, height } = display.workAreaSize; const { width, height } = display.workAreaSize;
// Vị trí cửa sổ ở góc phải dưới của màn hình đã chọn // Vị trí cửa sổ ở góc phải dưới của màn hình đã chọn
win = new BrowserWindow({ win = new BrowserWindow({
width: 600, width: 600,
height: 200, height: 200,
x: 0, // Đặt cửa sổ ở góc phải x: 0, // Đặt cửa sổ ở góc phải
y: 0, // Đặt cửa sổ ở góc dưới y: 0, // Đặt cửa sổ ở góc dưới
alwaysOnTop: true, // Cửa sổ luôn nằm trên các cửa sổ khác alwaysOnTop: true, // Cửa sổ luôn nằm trên các cửa sổ khác
resizable: true, // Không cho phép thay đổi kích thước resizable: true, // Không cho phép thay đổi kích thước
icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"), icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
webPreferences: { webPreferences: {
preload: path.join(__dirname, "preload.mjs"), preload: path.join(__dirname, 'preload.mjs'),
}, },
}); });
// Test active push message to Renderer-process. // Test active push message to Renderer-process.
win.webContents.on("did-finish-load", () => { win.webContents.on('did-finish-load', () => {
win?.webContents.send("main-process-message", new Date().toLocaleString()); win?.webContents.send('main-process-message', new Date().toLocaleString());
}); });
win.setPosition(width - 600, height - 200); win.setPosition(width - 600, height - 200);
if (VITE_DEV_SERVER_URL) { if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL); win.loadURL(VITE_DEV_SERVER_URL);
} else { } else {
// win.loadFile('dist/index.html') // win.loadFile('dist/index.html')
win.loadFile(path.join(RENDERER_DIST, "index.html")); win.loadFile(path.join(RENDERER_DIST, 'index.html'));
} }
} }
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits // for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q. // explicitly with Cmd + Q.
app.on("window-all-closed", () => { app.on('window-all-closed', () => {
if (process.platform !== "darwin") { if (process.platform !== 'darwin') {
app.quit(); app.quit();
win = null; win = null;
} }
}); });
app.on("activate", () => { app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the // On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open. // dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();
} }
}); });
app.whenReady().then(createWindow); app.whenReady().then(createWindow);
// IPC Main Events // IPC Main Events
ipcMain.on("open-devtools", (event) => { ipcMain.on('open-devtools', (event) => {
const webContents = event.sender; const webContents = event.sender;
webContents.openDevTools({ mode: "detach" }); webContents.openDevTools({ mode: 'detach' });
}); });
// Xử lý connect socket // Xử lý connect socket
ipcMain.handle("connect-socket", async (_) => { ipcMain.handle('connect-socket', async (_) => {
const socket = io("https://zulip.ipsupply.com.au", { const socket = io('https://zulip.ipsupply.com.au', {
path: "/apac-custom/socket.io", path: '/apac-custom/socket.io',
secure: true, secure: true,
query: { token: process.env.API_KEY }, query: { token: import.meta.env.VITE_API_KEY },
rejectUnauthorized: false, rejectUnauthorized: false,
}); });
if (!socket.connected) { if (!socket.connected) {
socket.connect(); socket.connect();
} }
socket.on("connect", () => { socket.on('connect', () => {
console.log(socket.connected); // true console.log(socket.connected); // true
}); });
socket.on("newNote", (data) => { socket.on('newNote', (data) => {
win?.webContents.send("newNote", data); win?.webContents.send('newNote', data);
}); });
}); });
ipcMain.handle("fetchMessages", async () => { ipcMain.handle('fetchMessages', async () => {
return await fetchMessages(); return await fetchMessages();
}); });
ipcMain.handle("fetchEmails", async () => { ipcMain.handle('fetchEmails', async () => {
return await fetchEmails(); return await fetchEmails();
}); });
ipcMain.handle("open-new-window", async () => { ipcMain.handle('open-new-window', async () => {
createMailWindow(); createMailWindow({ RENDERER_DIST });
}); });
ipcMain.handle("add-email", async (_, email: string) => { ipcMain.handle('add-email', async (_, email: string) => {
return addEmail(email); return addEmail(email);
}); });
ipcMain.handle("del-email", async (_, id: number) => { ipcMain.handle('del-email', async (_, id: number) => {
return deleteEmail(id); return deleteEmail(id);
}); });
ipcMain.handle("show-notification", async (_, { title, body }) => { ipcMain.handle('show-notification', async (_, { title, body }) => {
const notification = new Notification({ const notification = new Notification({
title, title,
body, body,
silent: false, silent: false,
}); });
notification.show(); notification.show();
}); });
// Funtions
export function createMailWindow() {
newWin = new BrowserWindow({
width: 600,
height: 400,
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
},
});
// Nếu đang phát triển, sử dụng HashRouter trong URL
const url = process.env.VITE_DEV_SERVER_URL
? `${process.env.VITE_DEV_SERVER_URL}/#/mails` // URL với HashRouter
: path.join(RENDERER_DIST, "index.html"); // Nếu build, dùng đúng path
newWin.loadURL(url);
// 👉 Mở DevTools sau khi load
newWin.webContents.openDevTools();
newWin.on("closed", () => {
newWin = null;
});
}

View File

@ -0,0 +1,25 @@
import { BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname, '..');
export function createMailWindow({ RENDERER_DIST }: { RENDERER_DIST: string }) {
let newWin: BrowserWindow | null = new BrowserWindow({
width: 600,
height: 400,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
},
});
const url = process.env.VITE_DEV_SERVER_URL ? `${process.env.VITE_DEV_SERVER_URL}/#/mails` : `file://${path.join(RENDERER_DIST, 'index.html')}#/mails`;
newWin.loadURL(url);
newWin.on('closed', () => {
newWin = null;
});
return newWin;
}

35
package-lock.json generated
View File

@ -16,14 +16,12 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"dotenv": "^16.5.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^7.5.3", "react-router-dom": "^7.5.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"utf-8-validate": "^6.0.5", "utf-8-validate": "^6.0.5",
"ws": "^8.18.2",
"zod": "^3.24.4" "zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
@ -3916,18 +3914,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand": { "node_modules/dotenv-expand": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
@ -8228,27 +8214,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@ -19,14 +19,12 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"axios": "^1.9.0", "axios": "^1.9.0",
"bufferutil": "^4.0.9", "bufferutil": "^4.0.9",
"dotenv": "^16.5.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^7.5.3", "react-router-dom": "^7.5.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"utf-8-validate": "^6.0.5", "utf-8-validate": "^6.0.5",
"ws": "^8.18.2",
"zod": "^3.24.4" "zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,61 +1,55 @@
import { config } from "dotenv"; import axios from '../instants/axios';
import axios from "../instants/axios";
config(); const API_KEY = import.meta.env.VITE_API_KEY;
const API_KEY = process.env.API_KEY;
export const getAllNote = async () => { export const getAllNote = async () => {
try { try {
const response = await axios({ const response = await axios({
method: "GET", method: 'GET',
url: "getAllNotes", url: 'getAllNotes',
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
return []; return [];
} }
}; };
export async function fetchMessages(onError?: (error: unknown) => void) { export async function fetchMessages(onError?: (error: unknown) => void) {
try { try {
const response = await axios.get(`getAllNotes?key=${API_KEY}`); const response = await axios.get(`getAllNotes?key=${API_KEY}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("Error fetching notes:", error); console.error('Error fetching notes:', error);
onError?.(error); onError?.(error);
} }
} }
export async function fetchEmails(onError?: (error: unknown) => void) { export async function fetchEmails(onError?: (error: unknown) => void) {
try { try {
const response = await axios.get(`emails?key=${API_KEY}`); const response = await axios.get(`emails?key=${API_KEY}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("Error fetching emails:", error); console.error('Error fetching emails:', error);
onError?.(error); onError?.(error);
} }
} }
export async function addEmail( export async function addEmail(email: string, onError?: (error: unknown) => void) {
email: string, try {
onError?: (error: unknown) => void const response = await axios.post(`add-email`, {
) { email: email,
try { key: API_KEY,
const response = await axios.post(`add-email`, { });
email: email,
key: API_KEY,
});
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("Error add email:", error); console.error('Error add email:', error);
onError?.(error); onError?.(error);
} }
} }
export async function deleteEmail(id: number) { export async function deleteEmail(id: number) {
await axios.delete(`delete-email/${id}?key=${API_KEY}`); await axios.delete(`delete-email/${id}?key=${API_KEY}`);
} }

View File

@ -1,34 +1,28 @@
import { Button, Modal, Text } from "@mantine/core"; import { Button, Modal, Text } from '@mantine/core';
interface ConfirmModalProps { interface ConfirmModalProps {
opened: boolean; opened: boolean;
title?: string; title?: string;
message: string; message: string;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
export default function ConfirmModal({ export default function ConfirmModal({ opened, title = 'Xác nhận', message, onConfirm, onCancel }: ConfirmModalProps) {
opened, return (
title = "Xác nhận", <Modal opened={opened} onClose={onCancel} title={title} centered>
message, <Text size="md" mb="xs">
onConfirm, {message}
onCancel, </Text>
}: ConfirmModalProps) {
return (
<Modal opened={opened} onClose={onCancel} title={title} centered>
<Text size="xs" mb="md">
{message}
</Text>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button variant="default" onClick={onCancel}> <Button size="xs" variant="default" onClick={onCancel}>
No No
</Button> </Button>
<Button color="red" onClick={onConfirm}> <Button size="xs" color="red" onClick={onConfirm}>
Yes Yes
</Button> </Button>
</div> </div>
</Modal> </Modal>
); );
} }

View File

@ -1,48 +1,62 @@
import { ActionIcon, Box, Paper, Textarea, Tooltip } from "@mantine/core"; import { ActionIcon, Box, Paper, Textarea, Tooltip } from '@mantine/core';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from '@mantine/hooks';
import { IconEye, IconEyeOff, IconTrash } from "@tabler/icons-react"; import { IconEye, IconEyeOff, IconTrash } from '@tabler/icons-react';
import { useMemo } from "react"; import { useEffect, useMemo, useState } from 'react';
export interface IMessageProps { export interface IMessageProps {
data: IMessage; data: IMessage;
} }
export default function Message({ data }: IMessageProps) { export default function Message({ data }: IMessageProps) {
const [show, { toggle }] = useDisclosure(false); const [show, { toggle }] = useDisclosure(false);
const messageContent = useMemo(() => { const [animation, setAnimation] = useState(false);
return show ? data.message : data.message.replace(/./g, "•");
}, [data.message, show]);
return ( const messageContent = useMemo(() => {
<Paper return show ? data.message : data.message.replace(/./g, '•');
key={data.message} }, [data.message, show]);
shadow="xs"
radius="md" // Tắt hiệu ứng nhấp nháy sau 1 giây
p="xs" useEffect(() => {
withBorder if (!animation) return;
className="flex items-center gap-3 w-full"
> const timer = setTimeout(() => {
<div className="flex items-center gap-2"> setAnimation(false);
<Textarea }, 5000);
defaultValue={messageContent} return () => clearTimeout(timer);
value={messageContent} }, [animation]);
className="flex-1"
variant="unstyled" useEffect(() => {
/> const showAnimation = new Date().getTime() - new Date(data.time).getTime() <= 3000;
<Box className="flex gap-1">
<Tooltip label="View"> setAnimation(showAnimation);
<ActionIcon onClick={toggle} variant="light" size="sm"> // eslint-disable-next-line react-hooks/exhaustive-deps
{show ? <IconEyeOff size={16} /> : <IconEye size={16} />} }, []);
</ActionIcon>
</Tooltip> return (
<Tooltip label="Delete"> <Paper
<ActionIcon variant="light" size="sm" color="red"> key={data.message}
<IconTrash size={16} /> shadow="xs"
</ActionIcon> radius="md"
</Tooltip> p="xs"
</Box> withBorder
</div> className={`flex items-center gap-3 w-full transition-all duration-300 ${animation ? 'animate-pulse !bg-blue-100' : ''}`}
</Paper> >
); <div className="flex items-center gap-2 w-full">
<Textarea defaultValue={messageContent} value={messageContent} className="flex-1" variant="unstyled" readOnly />
<Box className="flex gap-1">
<Tooltip label="View">
<ActionIcon onClick={toggle} variant="light" size="sm">
{show ? <IconEyeOff size={16} /> : <IconEye size={16} />}
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon variant="light" size="sm" color="red">
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Box>
</div>
</Paper>
);
} }

View File

@ -1,178 +1,160 @@
import { import { ActionIcon, Box, Button, Container, LoadingOverlay, Paper, ScrollArea, Text, TextInput } from '@mantine/core';
ActionIcon, import { useForm, zodResolver } from '@mantine/form';
Box, import { useDisclosure } from '@mantine/hooks';
Button, import { IconMail, IconTrash } from '@tabler/icons-react'; // Icon xóa
Container, import { nanoid } from 'nanoid'; // Để tạo ID cho mỗi email mới
LoadingOverlay, import { useEffect, useRef, useState } from 'react';
Paper, import { z } from 'zod';
ScrollArea, import { ConfirmModal } from '../components';
Text, import { showNotification } from '../ultils/fn';
TextInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { IconMail, IconTrash } from "@tabler/icons-react"; // Icon xóa
import { nanoid } from "nanoid"; // Để tạo ID cho mỗi email mới
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { ConfirmModal } from "../components";
import { showNotification } from "../ultils/fn";
const schema = z.object({ const schema = z.object({
email: z.string().email("Invalid email"), email: z.string().email('Invalid email'),
}); });
function MailsPage() { function MailsPage() {
const viewport = useRef<HTMLDivElement>(null); const viewport = useRef<HTMLDivElement>(null);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [clickEmail, setClickEmail] = useState<IEmail | null>(null); const [clickEmail, setClickEmail] = useState<IEmail | null>(null);
const [emails, setEmails] = useState<IEmail[]>([]); // Dùng để lưu danh sách email const [emails, setEmails] = useState<IEmail[]>([]); // Dùng để lưu danh sách email
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
email: "", email: '',
}, },
validate: zodResolver(schema), validate: zodResolver(schema),
});
const scrollToBottom = () =>
viewport.current!.scrollTo({
top: viewport.current!.scrollHeight,
behavior: "smooth",
}); });
// Hàm thêm email vào danh sách const scrollToBottom = () =>
const handleAddEmail = async ({ email }: { email: string }) => { viewport.current!.scrollTo({
if (email.trim() !== "") { top: viewport.current!.scrollHeight,
try { behavior: 'smooth',
setLoading(true);
const response = await window.ipcRenderer.invoke("add-email", email);
if (!response) return;
console.log("%csrc/pages/mails.tsx:40 {response}", "color: #007acc;", {
response,
}); });
await fetchEmails(); // Hàm thêm email vào danh sách
const handleAddEmail = async ({ email }: { email: string }) => {
if (email.trim() !== '') {
try {
setLoading(true);
const response = await window.ipcRenderer.invoke('add-email', email);
showNotification("Thành công", "Đã thêm email mới"); if (!response) return;
scrollToBottom(); console.log('%csrc/pages/mails.tsx:40 {response}', 'color: #007acc;', {
setClickEmail(null); response,
form.reset(); });
} catch (error) {
console.log("%csrc/pages/mails.tsx:65 error", "color: #007acc;", error);
} finally {
setLoading(false);
}
}
};
const handleDeleteEmail = async (id: number | undefined) => { await fetchEmails();
try {
if (!id) return;
setLoading(true);
await window.ipcRenderer.invoke("del-email", id);
await fetchEmails(); showNotification('Thành công', 'Đã thêm email mới');
close();
showNotification("Thông báo", "Đã xóa email"); scrollToBottom();
} catch (error) { setClickEmail(null);
console.log("%csrc/pages/mails.tsx:88 error", "color: #007acc;", error); form.reset();
} finally { } catch (error) {
setLoading(false); console.log('%csrc/pages/mails.tsx:65 error', 'color: #007acc;', error);
} } finally {
}; setLoading(false);
}
const fetchEmails = async () => { }
try {
await window.ipcRenderer.invoke("connect-socket");
setLoading(true);
const mails = await window.ipcRenderer.invoke("fetchEmails");
console.log("%csrc/App.tsx:29 {mails}", "color: #007acc;", {
mails,
});
setEmails(mails);
} catch (error) {
console.error("Failed to connect socket", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEmails();
return () => {
// window.ipcRenderer.removeListener("new-note", handleNewNote);
}; };
}, []);
return ( const handleDeleteEmail = async (id: number | undefined) => {
<Container style={{ paddingTop: 20 }} className="overflow-hidden"> try {
<Paper shadow="xs" p="md" style={{ marginBottom: 20 }}> if (!id) return;
<form onSubmit={form.onSubmit(handleAddEmail)}> setLoading(true);
<TextInput await window.ipcRenderer.invoke('del-email', id);
{...form.getInputProps("email")}
label="Enter Email"
placeholder="example@example.com"
/>
<Button type="submit" fullWidth style={{ marginTop: 10 }}>
Add Email
</Button>
</form>
</Paper>
<ScrollArea viewportRef={viewport} h={200} type="auto" pb={"lg"}> await fetchEmails();
<Box close();
className="flex flex-col gap-4"
size="sm"
style={{ marginTop: 20 }}
>
{emails.length > 0 ? (
emails.map((item) => (
<Box key={nanoid()} className="flex items-center">
<ActionIcon size={"md"} variant="outline" className="mr-3">
<IconMail size={18} />
</ActionIcon>
<Text style={{ flexGrow: 1 }}>{item.email}</Text>
<ActionIcon
onClick={() => {
setClickEmail(item);
open();
}}
color="red"
size="md"
variant="light"
>
<IconTrash size={16} />
</ActionIcon>
</Box>
))
) : (
<Text className="!text-gray-500">No emails added yet.</Text>
)}
</Box>
</ScrollArea>
<LoadingOverlay visible={loading} /> showNotification('Thông báo', 'Đã xóa email');
} catch (error) {
console.log('%csrc/pages/mails.tsx:88 error', 'color: #007acc;', error);
} finally {
setLoading(false);
}
};
<ConfirmModal const fetchEmails = async () => {
title="Warning" try {
message="Caaj" await window.ipcRenderer.invoke('connect-socket');
opened={opened}
onCancel={close} setLoading(true);
onConfirm={() => handleDeleteEmail(clickEmail?.id)}
/> const mails = await window.ipcRenderer.invoke('fetchEmails');
</Container>
); console.log('%csrc/App.tsx:29 {mails}', 'color: #007acc;', {
mails,
});
setEmails(mails);
} catch (error) {
console.error('Failed to connect socket', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEmails();
return () => {
// window.ipcRenderer.removeListener("new-note", handleNewNote);
};
}, []);
return (
<Container style={{ paddingTop: 20 }} className="overflow-hidden">
<Paper shadow="xs" p="md" style={{ marginBottom: 20 }}>
<form onSubmit={form.onSubmit(handleAddEmail)}>
<TextInput {...form.getInputProps('email')} label="Enter Email" placeholder="example@example.com" />
<Button type="submit" fullWidth style={{ marginTop: 10 }}>
Add Email
</Button>
</form>
</Paper>
<ScrollArea viewportRef={viewport} h={200} type="auto" pb={'lg'}>
<Box className="flex flex-col gap-4" size="sm" style={{ marginTop: 20 }}>
{emails.length > 0 ? (
emails.map((item) => (
<Box key={nanoid()} className="flex items-center">
<ActionIcon size={'md'} variant="outline" className="mr-3">
<IconMail size={18} />
</ActionIcon>
<Text style={{ flexGrow: 1 }}>{item.email}</Text>
<ActionIcon
onClick={() => {
setClickEmail(item);
open();
}}
color="red"
size="md"
variant="light"
>
<IconTrash size={16} />
</ActionIcon>
</Box>
))
) : (
<Text className="!text-gray-500">No emails added yet.</Text>
)}
</Box>
</ScrollArea>
<LoadingOverlay visible={loading} />
<ConfirmModal
title="Warning"
message={`Bạn có chắc chắn muốn xóa email "${clickEmail?.email}" không?`}
opened={opened}
onCancel={close}
onConfirm={() => handleDeleteEmail(clickEmail?.id)}
/>
</Container>
);
} }
export default MailsPage; export default MailsPage;

View File

@ -1,99 +1,106 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { ActionIcon, Box, ScrollAreaAutosize, Tooltip } from "@mantine/core"; import { ActionIcon, Box, LoadingOverlay, ScrollAreaAutosize, Tooltip } from '@mantine/core';
import { useViewportSize } from "@mantine/hooks"; // 🔥 thêm cái này import { useViewportSize } from '@mantine/hooks'; // 🔥 thêm cái này
import { IconDotsVertical, IconMail } from "@tabler/icons-react"; import { IconDotsVertical, IconMail } from '@tabler/icons-react';
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react';
import { Message, Settings } from "../components"; import { Message, Settings } from '../components';
import { showNotification } from "../ultils/fn"; import { showNotification } from '../ultils/fn';
function MainPage() { function MainPage() {
const [messages, setMessages] = useState<IMessage[]>([]); const [messages, setMessages] = useState<IMessage[]>([]);
const viewportRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const { height } = useViewportSize(); // lấy kích thước cửa sổ const { height } = useViewportSize(); // lấy kích thước cửa sổ
const scrollToBottom = () => { const scrollToBottom = () => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (viewport) { if (viewport) {
viewport.scrollTo({ viewport.scrollTo({
top: viewport.scrollHeight, top: viewport.scrollHeight,
behavior: "smooth", behavior: 'smooth',
}); });
} }
};
const fetchMessages = async () => {
const messages = await window.ipcRenderer.invoke("fetchMessages");
setMessages(messages);
};
useEffect(() => {
const connectSocket = async () => {
try {
await window.ipcRenderer.invoke("connect-socket");
await fetchMessages();
setTimeout(scrollToBottom, 100);
} catch (error) {
console.error("Failed to connect socket", error);
}
}; };
connectSocket(); const fetchMessages = async () => {
window.ipcRenderer?.onNewNote(async (data: any) => { const messages = await window.ipcRenderer.invoke('fetchMessages');
console.log("Received newNote data:", data);
showNotification('Có tin nhắn mới', data.message)
await fetchMessages();
scrollToBottom()
});
}, []);
const openMailsWindow = () => { if (!messages || !Array.isArray(messages)) {
window.ipcRenderer.invoke("open-new-window"); showNotification('Lỗi fetch notes', `Error: ${JSON.stringify(messages)}`);
}; return;
}
// 🧠 Tính chiều cao dynamic (ví dụ trừ header 80px + padding 30px) setMessages(messages);
const scrollAreaHeight = height - 70; // Bạn chỉnh số này nếu muốn };
return ( useEffect(() => {
<Box className="flex flex-col !overflow-hidden h-full"> const connectSocket = async () => {
{/* Header */} try {
<header className="p-4 flex items-center justify-between sticky top-0 border-b border-gray-100 pb-2 bg-white z-10"> await window.ipcRenderer.invoke('connect-socket');
<Tooltip label="Mail"> setLoading(true);
<ActionIcon await fetchMessages();
onClick={openMailsWindow} setTimeout(scrollToBottom, 100);
variant="light" } catch (error) {
radius="md" console.error('Failed to connect socket', error);
size="lg" } finally {
> setLoading(false);
<IconMail size={18} /> }
</ActionIcon> };
</Tooltip>
<div className="text-xl font-semibold">Notes</div> connectSocket();
window.ipcRenderer?.onNewNote(async (data: any) => {
console.log('Received newNote data:', data);
showNotification('Có tin nhắn mới', data.message);
await fetchMessages();
scrollToBottom();
});
}, []);
<Settings> const openMailsWindow = () => {
<Tooltip label="Settings"> window.ipcRenderer.invoke('open-new-window');
<ActionIcon variant="subtle" radius="md" size="lg"> };
<IconDotsVertical size={18} />
</ActionIcon>
</Tooltip>
</Settings>
</header>
{/* Message List */} // 🧠 Tính chiều cao dynamic (ví dụ trừ header 80px + padding 30px)
<ScrollAreaAutosize const scrollAreaHeight = height - 70; // Bạn chỉnh số này nếu muốn
viewportRef={viewportRef}
h={scrollAreaHeight} // 👈 set height động theo window size return (
type="auto" <Box className="flex flex-col !overflow-hidden h-full">
> {/* Header */}
<Box className="flex flex-col gap-3 w-full overflow-hidden p-2"> <header className="p-4 flex items-center justify-between sticky top-0 border-b border-gray-100 pb-2 bg-white z-10">
{messages.map((item) => ( <Tooltip label="Mail">
<Message key={item.message + item.id} data={item} /> <ActionIcon onClick={openMailsWindow} variant="light" radius="md" size="lg">
))} <IconMail size={18} />
</ActionIcon>
</Tooltip>
<div className="text-xl font-semibold">Notes</div>
<Settings>
<Tooltip label="Settings">
<ActionIcon variant="subtle" radius="md" size="lg">
<IconDotsVertical size={18} />
</ActionIcon>
</Tooltip>
</Settings>
</header>
{/* Message List */}
<ScrollAreaAutosize
viewportRef={viewportRef}
h={scrollAreaHeight} // 👈 set height động theo window size
type="auto"
>
<Box className="flex flex-col gap-3 w-full overflow-hidden p-2">
{messages.map((item) => (
<Message key={item.message + item.id} data={item} />
))}
</Box>
</ScrollAreaAutosize>
<LoadingOverlay visible={loading} />
</Box> </Box>
</ScrollAreaAutosize> );
</Box>
);
} }
export default MainPage; export default MainPage;

25
src/vite-env.d.ts vendored
View File

@ -2,23 +2,22 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare global { declare global {
interface Window { interface Window {
electronAPI: { electronAPI: {
// openDevTools: () => void; // openDevTools: () => void;
onNewNote: (data: any) => void; onNewNote: (data: any) => void;
}; };
} }
} }
interface IMessage { interface IMessage {
id: number; id: number;
sender: string; sender: string;
time: number; time: number;
message: string; message: string;
animation?: boolean;
} }
interface IEmail { interface IEmail {
id: number; id: number;
email: string; email: string;
} }

28
tailwind.config.js Normal file
View File

@ -0,0 +1,28 @@
// tailwind.config.js
export default {
theme: {
extend: {
keyframes: {
blink: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0' },
},
flash: {
'0%, 100%': { opacity: '1' },
'25%, 75%': { opacity: '0' },
'50%': { opacity: '1' },
},
pop: {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.05)' },
'100%': { transform: 'scale(1)' },
},
},
animation: {
blink: 'blink 1s ease-in-out',
flash: 'flash 1s ease-in-out',
pop: 'pop 0.3s ease-in-out',
},
},
},
};