diff --git a/package-lock.json b/package-lock.json index 954a2b6..a15292a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new-item-app", - "version": "1.0.3", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new-item-app", - "version": "1.0.3", + "version": "1.0.5", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/src/main/index.ts b/src/main/index.ts index 9b80fc8..42dab10 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -89,6 +89,7 @@ function createTray() { label: 'Show', click: () => { mainWindow?.show() + mainWindow?.focus() } }, { @@ -147,6 +148,16 @@ app.whenReady().then(() => { // Create tray icon createTray() + // Lắng nghe khi hiện cửa sổ + mainWindow?.on('show', () => { + mainWindow?.webContents.send('window-event', 'show') + }) + + // Lắng nghe khi ẩn cửa sổ + mainWindow?.on('hide', () => { + mainWindow?.webContents.send('window-event', 'hide') + }) + app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. @@ -175,7 +186,6 @@ ipcMain.handle('save-config-file', async (_event, jsonContent: any) => { try { let fileData: any = {} - // 👀 Kiểm tra nếu file đã tồn tại if (fs.existsSync(filePath)) { const rawData = fs.readFileSync(filePath, 'utf-8') fileData = JSON.parse(rawData) @@ -221,6 +231,7 @@ ipcMain.handle('get-config-file', async () => { ipcMain.handle('show-window', async () => { mainWindow?.show() + mainWindow?.focus() }) ipcMain.handle('hide-window', async () => { diff --git a/src/preload/index.ts b/src/preload/index.ts index f896620..9d2036c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,29 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, shell } from 'electron' import { electronAPI } from '@electron-toolkit/preload' -// Custom APIs for renderer -const api = {} - -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('api', api) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = api +const customAPI = { + ipcRenderer: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args), + on: (channel: string, listener: (...args: any[]) => void) => + ipcRenderer.on(channel, (_, ...args) => listener(...args)), + removeAllListeners: (channel: string) => ipcRenderer.removeAllListeners(channel) + }, + openExternal: (url: string) => shell.openExternal(url) } -contextBridge.exposeInMainWorld('electron', { - ipcRenderer: { - invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args) +// Gộp cả electronAPI và customAPI +const mergedAPI = { + ...electronAPI, + ...customAPI +} + +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', mergedAPI) + } catch (error) { + console.error('Failed to expose electron API:', error) } -}) +} else { + // @ts-ignore + window.electron = mergedAPI +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 555e6da..fb53295 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import { Notifications } from '@mantine/notifications' import '@mantine/notifications/styles.css' import { Product } from '@renderer/types/Product' import { IconDotsVertical } from '@tabler/icons-react' -import { Suspense, useCallback, useEffect, useState } from 'react' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' import { listHotItem } from './api/products' import CardItem from './components/CardItem' import ConfigModal from './components/ConfigModal' @@ -32,20 +32,36 @@ function App(): React.JSX.Element { const [newItems, setnewItems] = useState>([]) const [hotItem, setHotItem] = useState([]) const [isLoading, setIsLoading] = useState(false) - const [timeoutNoti, setTimeoutNoti] = useState(2000) + const [jsonData, setJsonData] = useState({ + noti_timeout: 2000, + delay_hide: 10000 + }) + const [mouseEnter, setmouseEnter] = useState(false) const [opened, { open, close }] = useDisclosure(false) + const refMouseEnterTimeout = useRef(null) + const refNotiTimeout = useRef(null) + const appStartedRef = useRef(false) + + const jsonDataRef = useRef(jsonData) + const loadTimeOutData = async () => { try { const data = await window.electron.ipcRenderer.invoke('get-config-file') if (!data?.noti_timeout) { - await window.electron.ipcRenderer.invoke('save-config-file', { noti_timeout: 2 }) + await window.electron.ipcRenderer.invoke('save-config-file', { + noti_timeout: 2, + delay_hide: 10 + }) return } - setTimeoutNoti(data.noti_timeout * 1000) + setJsonData({ + noti_timeout: data.noti_timeout * 1000, + delay_hide: data.delay_hide * 1000 + }) } catch (error) { console.log('%csrc/renderer/src/App.tsx:48 error', 'color: #007acc;', error) } @@ -65,17 +81,13 @@ function App(): React.JSX.Element { window.electron.ipcRenderer.invoke('show-window') - let timeoutId: NodeJS.Timeout | null = null + clearTimeouts() - if (timeoutId) { - clearTimeout(timeoutId) - } + if (isHotItem(data.data) || mouseEnter) return - if (isHotItem(data.data)) return - - timeoutId = setTimeout(() => { + refNotiTimeout.current = setTimeout(() => { window.electron.ipcRenderer.invoke('hide-window') - }, timeoutNoti) + }, jsonData.noti_timeout) } } @@ -86,7 +98,7 @@ function App(): React.JSX.Element { pusher.unsubscribe(PUSHER_CHANNEL) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timeoutNoti, hotItem]) + }, [jsonData.noti_timeout, hotItem, mouseEnter]) useEffect(() => { // load timeout config form local @@ -116,6 +128,77 @@ function App(): React.JSX.Element { [hotItem] ) + const handleMouseEnter = useCallback(() => { + setmouseEnter(true) + + clearTimeouts() + }, []) + + // Đây là hàm không phụ thuộc jsonData nữa, lấy delay_hide từ ref + const handleMouseLeave = useCallback(() => { + setmouseEnter(false) + if (refMouseEnterTimeout.current) { + clearTimeout(refMouseEnterTimeout.current) + } + refMouseEnterTimeout.current = setTimeout(() => { + window.electron.ipcRenderer.invoke('hide-window') + }, jsonDataRef.current.delay_hide) + }, []) + + const initApp = useCallback(() => { + if (appStartedRef.current) return + appStartedRef.current = true + + window.focus() + + document.addEventListener('mouseenter', handleMouseEnter) + document.addEventListener('mouseleave', handleMouseLeave) + }, [handleMouseEnter, handleMouseLeave]) + + // Phần event lắng nghe window-event thì giữ nguyên + + // Phần clean up khi window ẩn: + const cleanUpApp = useCallback(() => { + document.removeEventListener('mouseenter', handleMouseEnter) + document.removeEventListener('mouseleave', handleMouseLeave) + clearTimeouts() + }, [handleMouseEnter, handleMouseLeave]) + + const clearTimeouts = () => { + const timeoutIds = [refMouseEnterTimeout, refNotiTimeout] + + timeoutIds.forEach((timeout) => { + if (!timeout.current) return + + clearTimeout(timeout.current) + timeout.current = null + }) + } + + useEffect(() => { + const handler = (type: any) => { + console.log('Window event:', type) + if (type === 'show') { + initApp() + } + if (type === 'hide') { + appStartedRef.current = false + cleanUpApp() + } + } + + window.electron.ipcRenderer.on('window-event', handler) + + return () => { + window.electron.ipcRenderer.removeAllListeners('window-event') + } + }, [initApp, cleanUpApp]) + + // Cập nhật ref mỗi khi jsonData thay đổi + useEffect(() => { + jsonDataRef.current = jsonData + }, [jsonData]) + return ( diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 2fcb354..24d0cc9 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -34,6 +34,11 @@ overflow: hidden; } +.text-overflow-1-line:hover { + text-decoration: underline; + cursor: pointer; +} + .item-picture { height: auto; width: 100%; diff --git a/src/renderer/src/components/CardItem.tsx b/src/renderer/src/components/CardItem.tsx index 0096eb4..fb6660d 100644 --- a/src/renderer/src/components/CardItem.tsx +++ b/src/renderer/src/components/CardItem.tsx @@ -2,6 +2,7 @@ import { Anchor, Box, Button, Card, Grid, Text, Title } from '@mantine/core' import { Product } from '@renderer/types/Product' import moment from 'moment' import TimeCounter from './TimeCounter' +import { linkToItem } from '@renderer/utils/fn' interface CardItemProps { item: Product @@ -9,6 +10,9 @@ interface CardItemProps { } const CardItem: React.FC = ({ item, hotItem }) => { + const handleClickExternalLink = () => { + window.electron?.openExternal(linkToItem(item)) + } return ( @@ -55,7 +59,13 @@ const CardItem: React.FC = ({ item, hotItem }) => { {item?.title} - + <Title + onClick={handleClickExternalLink} + order={4} + c="#0202bf" + mb={4} + className="text-overflow-1-line" + > {item?.title} diff --git a/src/renderer/src/components/ConfigModal.tsx b/src/renderer/src/components/ConfigModal.tsx index bacba52..cbe6b44 100644 --- a/src/renderer/src/components/ConfigModal.tsx +++ b/src/renderer/src/components/ConfigModal.tsx @@ -9,10 +9,12 @@ export interface IConfigModalProps extends ModalProps { export default function ConfigModal(props: IConfigModalProps) { const form = useForm({ initialValues: { - timeout: 30 // default timeout 30s + timeout: 10, // default timeout 30s + delay_hide: 10 }, validate: { - timeout: (value) => (value <= 0 ? 'Timeout must be greater than 0' : null) + timeout: (value) => (value <= 0 ? 'Timeout must be greater than 0' : null), + delay_hide: (value) => (value <= 0 ? 'Delay must be greater than 0' : null) } }) @@ -20,7 +22,8 @@ export default function ConfigModal(props: IConfigModalProps) { if (!form.isValid()) return // Nếu form lỗi thì không save const jsonContent = { - noti_timeout: form.values.timeout + noti_timeout: form.values.timeout, + delay_hide: form.values.delay_hide } try { @@ -43,7 +46,7 @@ export default function ConfigModal(props: IConfigModalProps) { if (!data) return - form.setValues({ timeout: data?.noti_timeout }) + form.setValues({ timeout: data?.noti_timeout, delay_hide: data.delay_hide }) } catch (error) { console.log( '%csrc/renderer/src/components/ConfigModal.tsx:41 error', @@ -69,6 +72,12 @@ export default function ConfigModal(props: IConfigModalProps) { min={1} {...form.getInputProps('timeout')} /> +