Compare commits

...

2 Commits

Author SHA1 Message Date
zelda 77019ec8b3 Merge pull request 'fix build env' (#1) from zelda.fix-env-build into main
Reviewed-on: #1
2025-05-06 21:50:45 +10:00
nkhangg 375725c7d7 fix build env 2025-05-06 18:50:07 +07:00
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": [
"dmg"
],
"artifactName": "${productName}-Mac-${version}-Installer.${ext}"
}, },
"win": { win: {
"target": [ target: [
{ {
"target": "nsis", target: 'nsis',
"arch": [ arch: ['x64'],
"x64"
]
}
],
"artifactName": "${productName}-Windows-${version}-Setup.${ext}"
}, },
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false
},
"linux": {
"target": [
"AppImage"
], ],
"artifactName": "${productName}-Linux-${version}.${ext}" 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-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,21 +17,19 @@ 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();
@ -46,12 +40,7 @@ function createWindow() {
// 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
@ -70,15 +59,15 @@ function createWindow() {
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);
@ -87,21 +76,21 @@ function createWindow() {
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) {
@ -112,17 +101,17 @@ app.on("activate", () => {
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,
}); });
@ -130,36 +119,36 @@ ipcMain.handle("connect-socket", async (_) => {
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,
@ -167,28 +156,3 @@ ipcMain.handle("show-notification", async (_, { title, body }) => {
}); });
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,15 +1,12 @@
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;
@ -24,7 +21,7 @@ export async function fetchMessages(onError?: (error: unknown) => void) {
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);
} }
} }
@ -34,15 +31,12 @@ export async function fetchEmails(onError?: (error: unknown) => void) {
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,
onError?: (error: unknown) => void
) {
try { try {
const response = await axios.post(`add-email`, { const response = await axios.post(`add-email`, {
email: email, email: email,
@ -51,7 +45,7 @@ export async function addEmail(
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);
} }
} }

View File

@ -1,4 +1,4 @@
import { Button, Modal, Text } from "@mantine/core"; import { Button, Modal, Text } from '@mantine/core';
interface ConfirmModalProps { interface ConfirmModalProps {
opened: boolean; opened: boolean;
@ -8,24 +8,18 @@ interface ConfirmModalProps {
onCancel: () => void; onCancel: () => void;
} }
export default function ConfirmModal({ export default function ConfirmModal({ opened, title = 'Xác nhận', message, onConfirm, onCancel }: ConfirmModalProps) {
opened,
title = "Xác nhận",
message,
onConfirm,
onCancel,
}: ConfirmModalProps) {
return ( return (
<Modal opened={opened} onClose={onCancel} title={title} centered> <Modal opened={opened} onClose={onCancel} title={title} centered>
<Text size="xs" mb="md"> <Text size="md" mb="xs">
{message} {message}
</Text> </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>

View File

@ -1,7 +1,7 @@
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;
@ -10,10 +10,29 @@ export interface IMessageProps {
export default function Message({ data }: IMessageProps) { export default function Message({ data }: IMessageProps) {
const [show, { toggle }] = useDisclosure(false); const [show, { toggle }] = useDisclosure(false);
const [animation, setAnimation] = useState(false);
const messageContent = useMemo(() => { const messageContent = useMemo(() => {
return show ? data.message : data.message.replace(/./g, "•"); return show ? data.message : data.message.replace(/./g, '•');
}, [data.message, show]); }, [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 ( return (
<Paper <Paper
key={data.message} key={data.message}
@ -21,15 +40,10 @@ export default function Message({ data }: IMessageProps) {
radius="md" radius="md"
p="xs" p="xs"
withBorder withBorder
className="flex items-center gap-3 w-full" 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"> <div className="flex items-center gap-2 w-full">
<Textarea <Textarea defaultValue={messageContent} value={messageContent} className="flex-1" variant="unstyled" readOnly />
defaultValue={messageContent}
value={messageContent}
className="flex-1"
variant="unstyled"
/>
<Box className="flex gap-1"> <Box className="flex gap-1">
<Tooltip label="View"> <Tooltip label="View">
<ActionIcon onClick={toggle} variant="light" size="sm"> <ActionIcon onClick={toggle} variant="light" size="sm">

View File

@ -1,25 +1,15 @@
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() {
@ -31,7 +21,7 @@ function MailsPage() {
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),
}); });
@ -39,31 +29,31 @@ function MailsPage() {
const scrollToBottom = () => const scrollToBottom = () =>
viewport.current!.scrollTo({ viewport.current!.scrollTo({
top: viewport.current!.scrollHeight, top: viewport.current!.scrollHeight,
behavior: "smooth", behavior: 'smooth',
}); });
// Hàm thêm email vào danh sách // Hàm thêm email vào danh sách
const handleAddEmail = async ({ email }: { email: string }) => { const handleAddEmail = async ({ email }: { email: string }) => {
if (email.trim() !== "") { if (email.trim() !== '') {
try { try {
setLoading(true); setLoading(true);
const response = await window.ipcRenderer.invoke("add-email", email); const response = await window.ipcRenderer.invoke('add-email', email);
if (!response) return; if (!response) return;
console.log("%csrc/pages/mails.tsx:40 {response}", "color: #007acc;", { console.log('%csrc/pages/mails.tsx:40 {response}', 'color: #007acc;', {
response, response,
}); });
await fetchEmails(); await fetchEmails();
showNotification("Thành công", "Đã thêm email mới"); showNotification('Thành công', 'Đã thêm email mới');
scrollToBottom(); scrollToBottom();
setClickEmail(null); setClickEmail(null);
form.reset(); form.reset();
} catch (error) { } catch (error) {
console.log("%csrc/pages/mails.tsx:65 error", "color: #007acc;", error); console.log('%csrc/pages/mails.tsx:65 error', 'color: #007acc;', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -74,14 +64,14 @@ function MailsPage() {
try { try {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
await window.ipcRenderer.invoke("del-email", id); await window.ipcRenderer.invoke('del-email', id);
await fetchEmails(); await fetchEmails();
close(); close();
showNotification("Thông báo", "Đã xóa email"); showNotification('Thông báo', 'Đã xóa email');
} catch (error) { } catch (error) {
console.log("%csrc/pages/mails.tsx:88 error", "color: #007acc;", error); console.log('%csrc/pages/mails.tsx:88 error', 'color: #007acc;', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -89,19 +79,19 @@ function MailsPage() {
const fetchEmails = async () => { const fetchEmails = async () => {
try { try {
await window.ipcRenderer.invoke("connect-socket"); await window.ipcRenderer.invoke('connect-socket');
setLoading(true); setLoading(true);
const mails = await window.ipcRenderer.invoke("fetchEmails"); const mails = await window.ipcRenderer.invoke('fetchEmails');
console.log("%csrc/App.tsx:29 {mails}", "color: #007acc;", { console.log('%csrc/App.tsx:29 {mails}', 'color: #007acc;', {
mails, mails,
}); });
setEmails(mails); setEmails(mails);
} catch (error) { } catch (error) {
console.error("Failed to connect socket", error); console.error('Failed to connect socket', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -119,27 +109,19 @@ function MailsPage() {
<Container style={{ paddingTop: 20 }} className="overflow-hidden"> <Container style={{ paddingTop: 20 }} className="overflow-hidden">
<Paper shadow="xs" p="md" style={{ marginBottom: 20 }}> <Paper shadow="xs" p="md" style={{ marginBottom: 20 }}>
<form onSubmit={form.onSubmit(handleAddEmail)}> <form onSubmit={form.onSubmit(handleAddEmail)}>
<TextInput <TextInput {...form.getInputProps('email')} label="Enter Email" placeholder="example@example.com" />
{...form.getInputProps("email")}
label="Enter Email"
placeholder="example@example.com"
/>
<Button type="submit" fullWidth style={{ marginTop: 10 }}> <Button type="submit" fullWidth style={{ marginTop: 10 }}>
Add Email Add Email
</Button> </Button>
</form> </form>
</Paper> </Paper>
<ScrollArea viewportRef={viewport} h={200} type="auto" pb={"lg"}> <ScrollArea viewportRef={viewport} h={200} type="auto" pb={'lg'}>
<Box <Box className="flex flex-col gap-4" size="sm" style={{ marginTop: 20 }}>
className="flex flex-col gap-4"
size="sm"
style={{ marginTop: 20 }}
>
{emails.length > 0 ? ( {emails.length > 0 ? (
emails.map((item) => ( emails.map((item) => (
<Box key={nanoid()} className="flex items-center"> <Box key={nanoid()} className="flex items-center">
<ActionIcon size={"md"} variant="outline" className="mr-3"> <ActionIcon size={'md'} variant="outline" className="mr-3">
<IconMail size={18} /> <IconMail size={18} />
</ActionIcon> </ActionIcon>
<Text style={{ flexGrow: 1 }}>{item.email}</Text> <Text style={{ flexGrow: 1 }}>{item.email}</Text>
@ -166,7 +148,7 @@ function MailsPage() {
<ConfirmModal <ConfirmModal
title="Warning" title="Warning"
message="Caaj" message={`Bạn có chắc chắn muốn xóa email "${clickEmail?.email}" không?`}
opened={opened} opened={opened}
onCancel={close} onCancel={close}
onConfirm={() => handleDeleteEmail(clickEmail?.id)} onConfirm={() => handleDeleteEmail(clickEmail?.id)}

View File

@ -1,14 +1,15 @@
/* 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ổ
@ -17,38 +18,47 @@ function MainPage() {
if (viewport) { if (viewport) {
viewport.scrollTo({ viewport.scrollTo({
top: viewport.scrollHeight, top: viewport.scrollHeight,
behavior: "smooth", behavior: 'smooth',
}); });
} }
}; };
const fetchMessages = async () => { const fetchMessages = async () => {
const messages = await window.ipcRenderer.invoke("fetchMessages"); const messages = await window.ipcRenderer.invoke('fetchMessages');
if (!messages || !Array.isArray(messages)) {
showNotification('Lỗi fetch notes', `Error: ${JSON.stringify(messages)}`);
return;
}
setMessages(messages); setMessages(messages);
}; };
useEffect(() => { useEffect(() => {
const connectSocket = async () => { const connectSocket = async () => {
try { try {
await window.ipcRenderer.invoke("connect-socket"); await window.ipcRenderer.invoke('connect-socket');
setLoading(true);
await fetchMessages(); await fetchMessages();
setTimeout(scrollToBottom, 100); setTimeout(scrollToBottom, 100);
} catch (error) { } catch (error) {
console.error("Failed to connect socket", error); console.error('Failed to connect socket', error);
} finally {
setLoading(false);
} }
}; };
connectSocket(); connectSocket();
window.ipcRenderer?.onNewNote(async (data: any) => { window.ipcRenderer?.onNewNote(async (data: any) => {
console.log("Received newNote data:", data); console.log('Received newNote data:', data);
showNotification('Có tin nhắn mới', data.message) showNotification('Có tin nhắn mới', data.message);
await fetchMessages(); await fetchMessages();
scrollToBottom() scrollToBottom();
}); });
}, []); }, []);
const openMailsWindow = () => { const openMailsWindow = () => {
window.ipcRenderer.invoke("open-new-window"); window.ipcRenderer.invoke('open-new-window');
}; };
// 🧠 Tính chiều cao dynamic (ví dụ trừ header 80px + padding 30px) // 🧠 Tính chiều cao dynamic (ví dụ trừ header 80px + padding 30px)
@ -59,12 +69,7 @@ function MainPage() {
{/* Header */} {/* Header */}
<header className="p-4 flex items-center justify-between sticky top-0 border-b border-gray-100 pb-2 bg-white z-10"> <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"> <Tooltip label="Mail">
<ActionIcon <ActionIcon onClick={openMailsWindow} variant="light" radius="md" size="lg">
onClick={openMailsWindow}
variant="light"
radius="md"
size="lg"
>
<IconMail size={18} /> <IconMail size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@ -92,6 +97,8 @@ function MainPage() {
))} ))}
</Box> </Box>
</ScrollAreaAutosize> </ScrollAreaAutosize>
<LoadingOverlay visible={loading} />
</Box> </Box>
); );
} }

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

@ -15,7 +15,6 @@ interface IMessage {
sender: string; sender: string;
time: number; time: number;
message: string; message: string;
animation?: boolean;
} }
interface IEmail { interface IEmail {

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',
},
},
},
};