diff --git a/eslint.config.mjs b/eslint.config.mjs index e4776d2..6b55ab9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,27 +5,29 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks' import eslintPluginReactRefresh from 'eslint-plugin-react-refresh' export default tseslint.config( - { ignores: ['**/node_modules', '**/dist', '**/out'] }, - tseslint.configs.recommended, - eslintPluginReact.configs.flat.recommended, - eslintPluginReact.configs.flat['jsx-runtime'], - { - settings: { - react: { - version: 'detect' - } - } - }, - { - files: ['**/*.{ts,tsx}'], - plugins: { - 'react-hooks': eslintPluginReactHooks, - 'react-refresh': eslintPluginReactRefresh + { ignores: ['**/node_modules', '**/dist', '**/out'] }, + tseslint.configs.recommended, + eslintPluginReact.configs.flat.recommended, + eslintPluginReact.configs.flat['jsx-runtime'], + { + settings: { + react: { + version: 'detect' + } + } }, - rules: { - ...eslintPluginReactHooks.configs.recommended.rules, - ...eslintPluginReactRefresh.configs.vite.rules - } - }, - eslintConfigPrettier + { + files: ['**/*.{ts,tsx}'], + plugins: { + 'react-hooks': eslintPluginReactHooks, + 'react-refresh': eslintPluginReactRefresh + }, + rules: { + ...eslintPluginReactHooks.configs.recommended.rules, + ...eslintPluginReactRefresh.configs.vite.rules, + 'react/prop-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off' + } + }, + eslintConfigPrettier ) diff --git a/package-lock.json b/package-lock.json index 8d7a9a7..a6c76e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@mantine/core": "^8.0.0", + "@mantine/form": "^8.0.0", "@mantine/hooks": "^8.0.0", "@mantine/notifications": "^8.0.0", "@mantine/vanilla-extract": "^8.0.0", + "@tabler/icons-react": "^3.31.0", "axios": "^1.9.0", "electron-updater": "^6.3.9", "moment": "^2.30.1", @@ -1803,6 +1805,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@mantine/form": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.0.0.tgz", + "integrity": "sha512-ErbbEFMEiRsK2Rn0jmFE5ohNJXHSMSbuJsL2vDUVsbIaXo6svw6ockw1WWGdiU8oEGqxM6Pd618yI9cJWNHF3g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, "node_modules/@mantine/hooks": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.0.0.tgz", @@ -2276,6 +2291,32 @@ "node": ">=10" } }, + "node_modules/@tabler/icons": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.31.0.tgz", + "integrity": "sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.31.0.tgz", + "integrity": "sha512-2rrCM5y/VnaVKnORpDdAua9SEGuJKVqPtWxeQ/vUVsgaUx30LDgBZph7/lterXxDY1IKR6NO//HDhWiifXTi3w==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.31.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -5660,7 +5701,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -7370,6 +7410,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", diff --git a/package.json b/package.json index cb4d2cc..961aee6 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@mantine/core": "^8.0.0", + "@mantine/form": "^8.0.0", "@mantine/hooks": "^8.0.0", "@mantine/notifications": "^8.0.0", "@mantine/vanilla-extract": "^8.0.0", + "@tabler/icons-react": "^3.31.0", "axios": "^1.9.0", "electron-updater": "^6.3.9", "moment": "^2.30.1", diff --git a/src/main/index.ts b/src/main/index.ts index 2a78249..42f40c8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,10 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { app, shell, BrowserWindow, ipcMain, screen, globalShortcut } from 'electron' -import { join } from 'path' +import path, { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' +import fs from 'fs' function createWindow(): void { // Get Screen width, height @@ -106,3 +109,56 @@ app.on('window-all-closed', () => { // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here. +// Custom events + +ipcMain.handle('save-config-file', async (_event, jsonContent: any) => { + // const filePath = path.join(__dirname, 'config-data.json') + const userDataPath = app.getPath('userData') + const filePath = path.join(userDataPath, 'config-data.json') + + 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) + } + + const now = new Date().getTime() + + // 📦 Ghi dữ liệu mới + const newData = { + ...jsonContent, // giữ dữ liệu mới (nếu bạn muốn merge thì sửa dòng này) + created_at: fileData.created_at || now, // nếu có created_at thì giữ nguyên, không có thì lấy now + updated_at: now // luôn cập nhật updated_at + } + + fs.writeFileSync(filePath, JSON.stringify(newData, null, 2), 'utf-8') + + return { success: true, path: filePath } + } catch (error: any) { + console.error('Error saving file:', error) + return { success: false, error: error.message } + } +}) + +ipcMain.handle('get-config-file', async () => { + const userDataPath = app.getPath('userData') + const filePath = path.join(userDataPath, 'config-data.json') + + try { + // 📂 Kiểm tra file có tồn tại không + if (!fs.existsSync(filePath)) { + return { success: false, error: 'Config file does not exist' } + } + + const rawData = fs.readFileSync(filePath, 'utf-8') + const fileData = JSON.parse(rawData) + + return fileData + } catch (error: any) { + console.error('Error reading config file:', error) + return null + } +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d18524..f896620 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,5 @@ -import { contextBridge } from 'electron' +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { contextBridge, ipcRenderer } from 'electron' import { electronAPI } from '@electron-toolkit/preload' // Custom APIs for renderer @@ -8,15 +9,21 @@ const api = {} // 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) - } + 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 + // @ts-ignore (define in dts) + window.electron = electronAPI + // @ts-ignore (define in dts) + window.api = api } + +contextBridge.exposeInMainWorld('electron', { + ipcRenderer: { + invoke: (channel: string, ...args: any[]) => ipcRenderer.invoke(channel, ...args) + } +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d4a5ebc..d35e19f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,9 +1,7 @@ -import '@mantine/core/styles.css' -import '@mantine/notifications/styles.css' - -import { Suspense, useEffect, useState } from 'react' -import { pusher } from './pusher' +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { + ActionIcon, Box, Container, List, @@ -11,27 +9,53 @@ import { MantineProvider, Skeleton, Text, - Title + Title, + Tooltip } from '@mantine/core' -import { theme } from './theme' -import CardItem from './components/CardItem' -import { Product } from '@renderer/types/Product' -import { listHotItem } from './api/products' +import '@mantine/core/styles.css' +import { useDisclosure } from '@mantine/hooks' import { Notifications } from '@mantine/notifications' +import '@mantine/notifications/styles.css' +import { Product } from '@renderer/types/Product' +import { IconDotsVertical } from '@tabler/icons-react' +import moment from 'moment' +import { Suspense, useCallback, useEffect, useState } from 'react' +import { listHotItem } from './api/products' +import CardItem from './components/CardItem' +import ConfigModal from './components/ConfigModal' +import { pusher } from './pusher' +import { theme } from './theme' import { myNotificationStore, playNotificationSound, prependNotification } from './utils/Notificaton' -import moment from 'moment' const PUSHER_CHANNEL = import.meta.env.VITE_PUSHER_CHANNEL const PUSHER_EVENT = 'App\\Events\\MessagePushed' function App(): React.JSX.Element { - const [newItem, setNewItem] = useState>([]) + const [newItems, setnewItems] = useState>([]) const [hotItem, setHotItem] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [timeout, setTimeout] = useState(2000) + + const [opened, { open, close }] = useDisclosure(false) + + 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 }) + return + } + + setTimeout(data.noti_timeout * 1000) + } catch (error) { + console.log('%csrc/renderer/src/App.tsx:48 error', 'color: #007acc;', error) + } + } useEffect(() => { // Subcribe Channel @@ -40,31 +64,34 @@ function App(): React.JSX.Element { // Listen Event const callback = (data: any) => { if (data.data && data.data?.id) { - setNewItem((prev: Array) => [data.data, ...prev]) + setnewItems((prev: Array) => [data.data, ...prev]) // Show notification - prependNotification({ - title: ( - - {data.data?.title} - - ), - message: ( - - - Price: ${data.data?.price}, Site:{' '} - {data.data?.from_site}, Seller:{' '} - {data.data?.seller} - + prependNotification( + { + title: ( + + {data.data?.title} + + ), + message: ( + + + Price: ${data.data?.price}, + Site: {data.data?.from_site}, + Seller: {data.data?.seller} + - - {moment(new Date()).format('HH:mm A')} - - - ), - autoClose: false, - color: 'orange' - }) + + {moment(new Date()).format('HH:mm A')} + + + ), + autoClose: isHotItem(data.data) ? false : timeout, + color: 'orange' + }, + () => isHotItem(data.data) + ) // Play sound effect playNotificationSound() @@ -77,16 +104,20 @@ function App(): React.JSX.Element { channel.unbind(PUSHER_EVENT, callback) pusher.unsubscribe(PUSHER_CHANNEL) } - }, []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeout, hotItem]) useEffect(() => { + // load timeout config form local + loadTimeOutData() + getListHotItem() }, []) const getListHotItem = async () => { setIsLoading(true) try { - let data = await listHotItem() + const data = await listHotItem() setHotItem(data) } catch (error) { console.log(error) @@ -95,6 +126,15 @@ function App(): React.JSX.Element { } } + const isHotItem = useCallback( + (item: Product) => { + return hotItem.some((obj: any) => + item.title.toLowerCase().includes(obj.name.toLowerCase()) + ) + }, + [hotItem] + ) + return ( @@ -109,6 +149,14 @@ function App(): React.JSX.Element { } > + + + + + + + + {isLoading ? ( {Array.from({ length: 5 }).map((_, index) => ( @@ -119,23 +167,19 @@ function App(): React.JSX.Element { ) : ( - {newItem?.length === 0 ? ( + {newItems?.length === 0 ? ( No new item found!!! ) : ( - newItem?.map((item: Product) => { + newItems?.map((item: Product) => { return ( - item.title - .toLowerCase() - .includes(obj.name.toLowerCase()) - )} + hotItem={isHotItem(item)} /> ) }) @@ -143,6 +187,8 @@ function App(): React.JSX.Element { )} + + ) diff --git a/src/renderer/src/api/products.ts b/src/renderer/src/api/products.ts index fff5b5f..280fd58 100644 --- a/src/renderer/src/api/products.ts +++ b/src/renderer/src/api/products.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import axios from './api-instance' export const listHotItem = async () => { diff --git a/src/renderer/src/components/ConfigModal.tsx b/src/renderer/src/components/ConfigModal.tsx new file mode 100644 index 0000000..96afdc5 --- /dev/null +++ b/src/renderer/src/components/ConfigModal.tsx @@ -0,0 +1,85 @@ +import { Modal, ModalProps, NumberInput, Button, Stack } from '@mantine/core' +import { useForm } from '@mantine/form' +import { useEffect } from 'react' + +export interface IConfigModalProps extends ModalProps { + onSaved?: () => void +} + +export default function ConfigModal(props: IConfigModalProps) { + const form = useForm({ + initialValues: { + timeout: 30 // default timeout 30s + }, + validate: { + timeout: (value) => (value <= 0 ? 'Timeout must be greater than 0' : null) + } + }) + + const handleSave = async () => { + if (!form.isValid()) return // Nếu form lỗi thì không save + + const jsonContent = { + noti_timeout: form.values.timeout + } + + try { + const result = await window.electron.ipcRenderer.invoke('save-config-file', jsonContent) + if (result.success) { + props.onClose?.() + + props.onSaved?.() + } else { + console.error('Failed to save file:', result.error) + } + } catch (error) { + console.error('IPC error:', error) + } + } + + const loadConfigData = async () => { + try { + const data = await window.electron.ipcRenderer.invoke('get-config-file') + + console.log( + '%csrc/renderer/src/components/ConfigModal.tsx:44 data', + 'color: #007acc;', + data + ) + if (!data) return + + form.setValues({ timeout: data?.noti_timeout }) + } catch (error) { + console.log( + '%csrc/renderer/src/components/ConfigModal.tsx:41 error', + 'color: #007acc;', + error + ) + } + } + + useEffect(() => { + if (props.opened) { + loadConfigData() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.opened]) + return ( + +
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 5905ed1..686e520 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,7 +5,7 @@ import { createRoot } from 'react-dom/client' import App from './App' createRoot(document.getElementById('root')!).render( - - - + + + ) diff --git a/src/renderer/src/pusher.ts b/src/renderer/src/pusher.ts index 6c7046f..d6fc5cc 100644 --- a/src/renderer/src/pusher.ts +++ b/src/renderer/src/pusher.ts @@ -1,6 +1,6 @@ import Pusher from 'pusher-js' export const pusher = new Pusher(import.meta.env.VITE_PUSHER_APP_KEY, { - cluster: 'ap4', - encrypted: true + cluster: 'ap4' + // encrypted: true }) diff --git a/src/renderer/src/utils/Notificaton.ts b/src/renderer/src/utils/Notificaton.ts index a1987fd..5cd3ec7 100644 --- a/src/renderer/src/utils/Notificaton.ts +++ b/src/renderer/src/utils/Notificaton.ts @@ -1,21 +1,54 @@ +// /* eslint-disable @typescript-eslint/explicit-function-return-type */ +// import { createNotificationsStore, NotificationData } from '@mantine/notifications' +// import notifySound from '../assets/notifty.mp3' + +// export const myNotificationStore = createNotificationsStore() + +// export function prependNotification(notification: NotificationData, callback?: () => boolean) { +// const id = notification.id ?? crypto.randomUUID() +// myNotificationStore.updateState((current) => { +// const existing = current.notifications.filter((n) => n.id !== id) +// return { +// ...current, +// notifications: [ +// { +// ...notification, +// id +// }, +// ...existing +// ] +// } +// }) +// } + +// export const playNotificationSound = () => { +// const audio = new Audio(notifySound) +// audio.play().catch((e) => console.error('Audio play failed:', e)) +// } +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { createNotificationsStore, NotificationData } from '@mantine/notifications' import notifySound from '../assets/notifty.mp3' export const myNotificationStore = createNotificationsStore() -export function prependNotification(notification: NotificationData) { +export function prependNotification(notification: NotificationData, callback?: () => boolean) { const id = notification.id ?? crypto.randomUUID() + myNotificationStore.updateState((current) => { const existing = current.notifications.filter((n) => n.id !== id) + const shouldPrepend = callback ? callback() : false + return { ...current, - notifications: [ - { - ...notification, - id - }, - ...existing - ] + notifications: shouldPrepend + ? [ + { ...notification, id }, // 👉 thêm lên đầu nếu callback = true + ...existing + ] + : [ + ...existing, + { ...notification, id } // 👉 thêm xuống cuối nếu callback = false + ] } }) }