Compare commits

..

7 Commits

Author SHA1 Message Date
Admin bdc9a49da2 update show hide window 2025-06-04 15:11:41 +07:00
nkhangg 6f9442a02e update start up 2025-06-03 21:58:23 +07:00
nkhangg 933bed2c81 Merge branch 'main' of https://gitea.nswteam.net/hoangvi.ng/new-item-app 2025-06-01 20:37:19 +07:00
Admin 72aa26c880 update view item 2025-05-30 16:49:13 +07:00
nkhangg f8354882a7 update name publisher 2025-05-28 23:35:43 +07:00
zelda d22267e3f8 Merge pull request 'add furture auto open' (#4) from zelda.add-config into main
Reviewed-on: #4
2025-05-28 11:26:26 +10:00
zelda 85665b39c4 Merge pull request 'remove notifi' (#3) from zelda.add-config into main
Reviewed-on: #3
2025-05-16 17:22:41 +10:00
10 changed files with 293 additions and 104 deletions

View File

@ -1,43 +1,59 @@
appId: com.electron.app
productName: New Item
directories:
buildResources: build
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
- resources/**
win:
executableName: New-item
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
target: nsis
executableName: New-item
publisherName: apactech
portable:
artifactName: ${name}-${version}.exe
nsis: # vẫn giữ để dùng nếu muốn cài đặt, nhưng sẽ không tạo nếu chỉ chọn portable
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: generic
url: https://apactech.io/auto-updates

13
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "new-item-app",
"version": "1.0.0",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new-item-app",
"version": "1.0.0",
"version": "1.0.5",
"hasInstallScript": true,
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
@ -20,7 +20,8 @@
"axios": "^1.9.0",
"electron-updater": "^6.3.9",
"moment": "^2.30.1",
"pusher-js": "^8.4.0"
"pusher-js": "^8.4.0",
"windows-shortcuts": "^0.1.6"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
@ -10979,6 +10980,12 @@
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/windows-shortcuts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/windows-shortcuts/-/windows-shortcuts-0.1.6.tgz",
"integrity": "sha512-kjkb3Hmmmg7jwnOb+29AOmoEEA1L/JeLsMOYovpLxYpuc+fN0R+pr8sMwep3JFhUZloxyw1XTzq8n3HugXkqBA==",
"license": "MIT"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@ -1,9 +1,9 @@
{
"name": "new-item-app",
"version": "1.0.3",
"description": "An Electron application with React and TypeScript",
"version": "1.0.5",
"description": "new item application is active",
"main": "./out/main/index.js",
"author": "example.com",
"author": "apactech.io",
"homepage": "https://electron-vite.org",
"scripts": {
"format": "prettier --write .",
@ -32,7 +32,8 @@
"axios": "^1.9.0",
"electron-updater": "^6.3.9",
"moment": "^2.30.1",
"pusher-js": "^8.4.0"
"pusher-js": "^8.4.0",
"windows-shortcuts": "^0.1.6"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",

View File

@ -5,10 +5,10 @@ import { app, BrowserWindow, globalShortcut, ipcMain, Menu, screen, shell, Tray
import fs from 'fs'
import path, { join } from 'path'
import icon from '../../resources/icon.png?asset'
import ws from 'windows-shortcuts'
let mainWindow: null | BrowserWindow = null
let isQuiting = false
const startupFlagFile = path.join(app.getPath('userData'), 'startup-set.flag')
function createWindow(): void {
// Get Screen width, height
@ -89,6 +89,7 @@ function createTray() {
label: 'Show',
click: () => {
mainWindow?.show()
mainWindow?.focus()
}
},
{
@ -112,16 +113,22 @@ function createTray() {
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
if (!fs.existsSync(startupFlagFile)) {
app.setLoginItemSettings({
openAtLogin: true,
path: process.execPath,
args: []
// const startupFlagFile = path.join(app.getPath('userData'), 'startup-set.flag')
const startupFolder = path.join(
app.getPath('appData'),
'Microsoft\\Windows\\Start Menu\\Programs\\Startup'
)
const shortcutPath = path.join(startupFolder, 'New Item.lnk')
if (!fs.existsSync(shortcutPath)) {
ws.create(shortcutPath, {
target: process.execPath,
workingDir: path.dirname(process.execPath),
runStyle: 1,
desc: 'Start New Item with Windows',
icon: process.execPath
})
fs.writeFileSync(startupFlagFile, 'ok')
}
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
@ -141,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.
@ -169,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)
@ -215,6 +231,7 @@ ipcMain.handle('get-config-file', async () => {
ipcMain.handle('show-window', async () => {
mainWindow?.show()
mainWindow?.focus()
})
ipcMain.handle('hide-window', async () => {

View File

@ -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
}

View File

@ -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<Array<Product>>([])
const [hotItem, setHotItem] = useState<any>([])
const [isLoading, setIsLoading] = useState<boolean>(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<NodeJS.Timeout | null>(null)
const refNotiTimeout = useRef<NodeJS.Timeout | null>(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 (
<MantineProvider theme={theme}>
<Notifications limit={10} store={myNotificationStore} position="top-right" />
@ -156,12 +239,6 @@ function App(): React.JSX.Element {
</Box>
) : (
newItems?.map((item: Product) => {
console.log({
hotItem,
item,
is_host: isHotItem(item)
})
return (
<CardItem
key={item?.id}

View File

@ -1,18 +1,18 @@
@keyframes borderPulse {
0% {
border-color: red;
border-color: #e03131;
}
50% {
border-color: black;
}
100% {
border-color: red;
border-color: #e03131;
}
}
.item-card {
animation: borderPulse 1s infinite;
border: 4px solid red;
border: 4px solid #e03131;
border-radius: 4px;
transition: border-color 0.3s ease;
padding: 10px;
@ -34,6 +34,11 @@
overflow: hidden;
}
.text-overflow-1-line:hover {
text-decoration: underline;
cursor: pointer;
}
.item-picture {
height: auto;
width: 100%;

View File

@ -1,7 +1,8 @@
import { Card, Grid, Title, Text, Badge, Button, Anchor } from '@mantine/core'
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,21 +10,62 @@ interface CardItemProps {
}
const CardItem: React.FC<CardItemProps> = ({ item, hotItem }) => {
const handleClickExternalLink = () => {
window.electron?.openExternal(linkToItem(item))
}
return (
<Card className="item-card" mb={4} p={8}>
<Card className="item-card" bg={'#f1f3f5'} mb={8} px={8} py={'xl'}>
<Grid>
<Grid.Col span={2}>
<Grid.Col
span={2}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{hotItem ? (
<Badge size="md" color="red" pos="absolute" radius={0}>
Hot
</Badge>
<Box
style={{
position: 'absolute',
top: 0,
right: 0,
width: 80,
height: 80,
backgroundColor: '#e03131',
clipPath: 'polygon(100% 0, 100% 100%, 0 0)',
display: 'flex',
justifyContent: 'end',
alignItems: 'flex-start',
paddingTop: 10
}}
>
<Text
c="white"
fw={700}
size="xs"
style={{
transform: 'rotate(45deg)',
paddingTop: '10px',
paddingRight: '8px'
}}
>
HOT
</Text>
</Box>
) : (
''
)}
<img src={item?.picture} alt={item?.title} className="item-picture" />
</Grid.Col>
<Grid.Col span={10}>
<Title order={4} c="#0202bf" mb={4} className="text-overflow-1-line">
<Title
onClick={handleClickExternalLink}
order={4}
c="#0202bf"
mb={4}
className="text-overflow-1-line"
>
{item?.title}
</Title>

View File

@ -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')}
/>
<NumberInput
label="Delay (seconds)"
placeholder="Enter timeout in seconds"
min={1}
{...form.getInputProps('delay_hide')}
/>
<Button type="submit" fullWidth>
Save

View File

@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const linkToItem = (item: any) => {
switch (item.from_site) {
case 'EBAY_AU':
return `https://www.ebay.com.au/itm/${item.id}`
case 'EBAY_ENCA':
return `https://www.ebay.ca/itm/${item.id}`
case 'EBAY_GB':
return `https://www.ebay.co.uk/itm/${item.id}`
default:
return `https://www.ebay.com/itm/${item.id}`
}
}