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
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@ -1,43 +1,34 @@
// @see - https://www.electron.build/configuration/configuration
{
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
"appId": "YourAppID",
"asar": true,
"productName": "YourAppName",
"directories": {
"output": "release/${version}"
},
"files": [
"dist",
"dist-electron"
],
"mac": {
"target": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"linux": {
"target": [
"AppImage"
],
"artifactName": "${productName}-Linux-${version}.${ext}"
}
$schema: 'https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json',
appId: 'b1730d2e-6f3c-4f92-9f4a-9f8e918b40d2',
asar: true,
productName: 'Zulip messages',
directories: {
output: 'release/${version}',
},
files: ['dist', 'dist-electron'],
mac: {
target: ['dmg'],
artifactName: '${productName}-Mac-${version}-Installer.${ext}',
},
win: {
target: [
{
target: 'nsis',
arch: ['x64'],
},
],
artifactName: '${productName}-Windows-${version}-Setup.${ext}',
},
nsis: {
oneClick: false,
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-explicit-any */
import { config } from "dotenv";
import { app, BrowserWindow, ipcMain, Notification, screen } from "electron";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { io } from "socket.io-client";
import { WebSocket } from "ws";
import { addEmail, deleteEmail, fetchEmails, fetchMessages } from "../src/apis";
import { app, BrowserWindow, ipcMain, Menu, Notification, screen } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { io } from 'socket.io-client';
import { addEmail, deleteEmail, fetchEmails, fetchMessages } from '../src/apis';
import { createMailWindow } from './windows/mails.window';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// dot env
config();
// The built directory structure
//
// ├─┬─┬ dist
@ -21,174 +17,142 @@ config();
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, "..");
let newWin: BrowserWindow | null = null;
process.env.APP_ROOT = path.join(__dirname, '..');
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
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 RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
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 RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, "public")
: RENDERER_DIST;
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST;
let win: BrowserWindow | null;
Menu.setApplicationMenu(null);
function createWindow() {
// Lấy thông tin tất cả các màn hình
const displays = screen.getAllDisplays();
// Lấy thông tin tất cả các màn hình
const displays = screen.getAllDisplays();
// Lấy vị trí con trỏ chuột
const cursorPoint = screen.getCursorScreenPoint();
// Lấy vị trí con trỏ chuột
const cursorPoint = screen.getCursorScreenPoint();
// Tìm màn hình có chứa con trỏ chuột
let display = displays.find((display) => {
const { x, y, width, height } = display.bounds;
return (
cursorPoint.x >= x &&
cursorPoint.x <= x + width &&
cursorPoint.y >= y &&
cursorPoint.y <= y + height
);
});
// Tìm màn hình có chứa con trỏ chuột
let display = displays.find((display) => {
const { x, y, width, height } = display.bounds;
return 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
if (!display) {
display = screen.getPrimaryDisplay();
}
// 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) {
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({
width: 600,
height: 200,
x: 0, // Đặt cửa sổ ở góc phả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
resizable: true, // Không cho phép thay đổi kích thước
icon: path.join(process.env.VITE_PUBLIC, "electron-vite.svg"),
webPreferences: {
preload: path.join(__dirname, "preload.mjs"),
},
});
win = new BrowserWindow({
width: 600,
height: 200,
x: 0, // Đặt cửa sổ ở góc phả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
resizable: true, // Không cho phép thay đổi kích thước
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
},
});
// Test active push message to Renderer-process.
win.webContents.on("did-finish-load", () => {
win?.webContents.send("main-process-message", new Date().toLocaleString());
});
// Test active push message to Renderer-process.
win.webContents.on('did-finish-load', () => {
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) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
// win.loadFile('dist/index.html')
win.loadFile(path.join(RENDERER_DIST, "index.html"));
}
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
// win.loadFile('dist/index.html')
win.loadFile(path.join(RENDERER_DIST, 'index.html'));
}
}
// 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
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
win = null;
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
win = null;
}
});
app.on("activate", () => {
// 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.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
app.on('activate', () => {
// 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.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
app.whenReady().then(createWindow);
// IPC Main Events
ipcMain.on("open-devtools", (event) => {
const webContents = event.sender;
webContents.openDevTools({ mode: "detach" });
ipcMain.on('open-devtools', (event) => {
const webContents = event.sender;
webContents.openDevTools({ mode: 'detach' });
});
// Xử lý connect socket
ipcMain.handle("connect-socket", async (_) => {
const socket = io("https://zulip.ipsupply.com.au", {
path: "/apac-custom/socket.io",
secure: true,
query: { token: process.env.API_KEY },
rejectUnauthorized: false,
});
ipcMain.handle('connect-socket', async (_) => {
const socket = io('https://zulip.ipsupply.com.au', {
path: '/apac-custom/socket.io',
secure: true,
query: { token: import.meta.env.VITE_API_KEY },
rejectUnauthorized: false,
});
if (!socket.connected) {
socket.connect();
}
if (!socket.connected) {
socket.connect();
}
socket.on("connect", () => {
console.log(socket.connected); // true
});
socket.on('connect', () => {
console.log(socket.connected); // true
});
socket.on("newNote", (data) => {
win?.webContents.send("newNote", data);
});
socket.on('newNote', (data) => {
win?.webContents.send('newNote', data);
});
});
ipcMain.handle("fetchMessages", async () => {
return await fetchMessages();
ipcMain.handle('fetchMessages', async () => {
return await fetchMessages();
});
ipcMain.handle("fetchEmails", async () => {
return await fetchEmails();
ipcMain.handle('fetchEmails', async () => {
return await fetchEmails();
});
ipcMain.handle("open-new-window", async () => {
createMailWindow();
ipcMain.handle('open-new-window', async () => {
createMailWindow({ RENDERER_DIST });
});
ipcMain.handle("add-email", async (_, email: string) => {
return addEmail(email);
ipcMain.handle('add-email', async (_, email: string) => {
return addEmail(email);
});
ipcMain.handle("del-email", async (_, id: number) => {
return deleteEmail(id);
ipcMain.handle('del-email', async (_, id: number) => {
return deleteEmail(id);
});
ipcMain.handle("show-notification", async (_, { title, body }) => {
const notification = new Notification({
title,
body,
silent: false,
});
notification.show();
ipcMain.handle('show-notification', async (_, { title, body }) => {
const notification = new Notification({
title,
body,
silent: false,
});
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",
"axios": "^1.9.0",
"bufferutil": "^4.0.9",
"dotenv": "^16.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.5.3",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.4",
"utf-8-validate": "^6.0.5",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"devDependencies": {
@ -3916,18 +3914,6 @@
"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": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz",
@ -8228,27 +8214,6 @@
"dev": true,
"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": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@ -19,14 +19,12 @@
"@types/ws": "^8.18.1",
"axios": "^1.9.0",
"bufferutil": "^4.0.9",
"dotenv": "^16.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.5.3",
"socket.io-client": "^4.8.1",
"tailwindcss": "^4.1.4",
"utf-8-validate": "^6.0.5",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"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 = process.env.API_KEY;
const API_KEY = import.meta.env.VITE_API_KEY;
export const getAllNote = async () => {
try {
const response = await axios({
method: "GET",
url: "getAllNotes",
});
try {
const response = await axios({
method: 'GET',
url: 'getAllNotes',
});
return response.data;
} catch (error) {
return [];
}
return response.data;
} catch (error) {
return [];
}
};
export async function fetchMessages(onError?: (error: unknown) => void) {
try {
const response = await axios.get(`getAllNotes?key=${API_KEY}`);
try {
const response = await axios.get(`getAllNotes?key=${API_KEY}`);
return response.data;
} catch (error) {
console.error("Error fetching notes:", error);
onError?.(error);
}
return response.data;
} catch (error) {
console.error('Error fetching notes:', error);
onError?.(error);
}
}
export async function fetchEmails(onError?: (error: unknown) => void) {
try {
const response = await axios.get(`emails?key=${API_KEY}`);
return response.data;
} catch (error) {
console.error("Error fetching emails:", error);
onError?.(error);
}
try {
const response = await axios.get(`emails?key=${API_KEY}`);
return response.data;
} catch (error) {
console.error('Error fetching emails:', error);
onError?.(error);
}
}
export async function addEmail(
email: string,
onError?: (error: unknown) => void
) {
try {
const response = await axios.post(`add-email`, {
email: email,
key: API_KEY,
});
export async function addEmail(email: string, onError?: (error: unknown) => void) {
try {
const response = await axios.post(`add-email`, {
email: email,
key: API_KEY,
});
return response.data;
} catch (error) {
console.error("Error add email:", error);
onError?.(error);
}
return response.data;
} catch (error) {
console.error('Error add email:', error);
onError?.(error);
}
}
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 {
opened: boolean;
title?: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
opened: boolean;
title?: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
opened,
title = "Xác nhận",
message,
onConfirm,
onCancel,
}: ConfirmModalProps) {
return (
<Modal opened={opened} onClose={onCancel} title={title} centered>
<Text size="xs" mb="md">
{message}
</Text>
export default function ConfirmModal({ opened, title = 'Xác nhận', message, onConfirm, onCancel }: ConfirmModalProps) {
return (
<Modal opened={opened} onClose={onCancel} title={title} centered>
<Text size="md" mb="xs">
{message}
</Text>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button variant="default" onClick={onCancel}>
No
</Button>
<Button color="red" onClick={onConfirm}>
Yes
</Button>
</div>
</Modal>
);
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button size="xs" variant="default" onClick={onCancel}>
No
</Button>
<Button size="xs" color="red" onClick={onConfirm}>
Yes
</Button>
</div>
</Modal>
);
}

View File

@ -1,48 +1,62 @@
import { ActionIcon, Box, Paper, Textarea, Tooltip } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff, IconTrash } from "@tabler/icons-react";
import { useMemo } from "react";
import { ActionIcon, Box, Paper, Textarea, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconEye, IconEyeOff, IconTrash } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
export interface IMessageProps {
data: IMessage;
data: IMessage;
}
export default function Message({ data }: IMessageProps) {
const [show, { toggle }] = useDisclosure(false);
const [show, { toggle }] = useDisclosure(false);
const messageContent = useMemo(() => {
return show ? data.message : data.message.replace(/./g, "•");
}, [data.message, show]);
const [animation, setAnimation] = useState(false);
return (
<Paper
key={data.message}
shadow="xs"
radius="md"
p="xs"
withBorder
className="flex items-center gap-3 w-full"
>
<div className="flex items-center gap-2">
<Textarea
defaultValue={messageContent}
value={messageContent}
className="flex-1"
variant="unstyled"
/>
<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>
);
const messageContent = useMemo(() => {
return show ? data.message : data.message.replace(/./g, '•');
}, [data.message, show]);
// Tắt hiệu ứng nhấp nháy sau 1 giây
useEffect(() => {
if (!animation) return;
const timer = setTimeout(() => {
setAnimation(false);
}, 5000);
return () => clearTimeout(timer);
}, [animation]);
useEffect(() => {
const showAnimation = new Date().getTime() - new Date(data.time).getTime() <= 3000;
setAnimation(showAnimation);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Paper
key={data.message}
shadow="xs"
radius="md"
p="xs"
withBorder
className={`flex items-center gap-3 w-full transition-all duration-300 ${animation ? 'animate-pulse !bg-blue-100' : ''}`}
>
<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 {
ActionIcon,
Box,
Button,
Container,
LoadingOverlay,
Paper,
ScrollArea,
Text,
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";
import { ActionIcon, Box, Button, Container, LoadingOverlay, Paper, ScrollArea, Text, 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({
email: z.string().email("Invalid email"),
email: z.string().email('Invalid email'),
});
function MailsPage() {
const viewport = useRef<HTMLDivElement>(null);
const [opened, { open, close }] = useDisclosure(false);
const [clickEmail, setClickEmail] = useState<IEmail | null>(null);
const [emails, setEmails] = useState<IEmail[]>([]); // Dùng để lưu danh sách email
const viewport = useRef<HTMLDivElement>(null);
const [opened, { open, close }] = useDisclosure(false);
const [clickEmail, setClickEmail] = useState<IEmail | null>(null);
const [emails, setEmails] = useState<IEmail[]>([]); // Dùng để lưu danh sách email
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: {
email: "",
},
validate: zodResolver(schema),
});
const scrollToBottom = () =>
viewport.current!.scrollTo({
top: viewport.current!.scrollHeight,
behavior: "smooth",
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: {
email: '',
},
validate: zodResolver(schema),
});
// 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);
if (!response) return;
console.log("%csrc/pages/mails.tsx:40 {response}", "color: #007acc;", {
response,
const scrollToBottom = () =>
viewport.current!.scrollTo({
top: viewport.current!.scrollHeight,
behavior: 'smooth',
});
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();
setClickEmail(null);
form.reset();
} catch (error) {
console.log("%csrc/pages/mails.tsx:65 error", "color: #007acc;", error);
} finally {
setLoading(false);
}
}
};
console.log('%csrc/pages/mails.tsx:40 {response}', 'color: #007acc;', {
response,
});
const handleDeleteEmail = async (id: number | undefined) => {
try {
if (!id) return;
setLoading(true);
await window.ipcRenderer.invoke("del-email", id);
await fetchEmails();
await fetchEmails();
close();
showNotification('Thành công', 'Đã thêm email mới');
showNotification("Thông báo", "Đã xóa email");
} catch (error) {
console.log("%csrc/pages/mails.tsx:88 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);
scrollToBottom();
setClickEmail(null);
form.reset();
} catch (error) {
console.log('%csrc/pages/mails.tsx:65 error', 'color: #007acc;', error);
} finally {
setLoading(false);
}
}
};
}, []);
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>
const handleDeleteEmail = async (id: number | undefined) => {
try {
if (!id) return;
setLoading(true);
await window.ipcRenderer.invoke('del-email', id);
<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>
await fetchEmails();
close();
<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
title="Warning"
message="Caaj"
opened={opened}
onCancel={close}
onConfirm={() => handleDeleteEmail(clickEmail?.id)}
/>
</Container>
);
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 (
<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;

View File

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

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

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