update save config
This commit is contained in:
parent
72f62d4cc5
commit
ed00ebd174
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "shotcut-app",
|
"name": "shotcut-app",
|
||||||
"version": "1.0.5",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "shotcut-app",
|
"name": "shotcut-app",
|
||||||
"version": "1.0.5",
|
"version": "1.0.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"windows-shortcuts": "^0.1.6"
|
"windows-shortcuts": "^0.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -10726,6 +10727,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"windows-shortcuts": "^0.1.6"
|
"windows-shortcuts": "^0.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,114 @@
|
||||||
/* eslint-disable prettier/prettier */
|
/* eslint-disable prettier/prettier */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||||
import { app, ipcMain, Menu, Tray } from 'electron'
|
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, Tray, screen, shell } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path, { join } from 'path'
|
||||||
import ws from 'windows-shortcuts'
|
import ws from 'windows-shortcuts'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from './shortcut'
|
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from './shotcut'
|
||||||
|
|
||||||
|
let mainWindow: null | BrowserWindow = null
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let isQuiting = false
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
// Get Screen width, height
|
||||||
|
const width = 800
|
||||||
|
const height = 600
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
...(process.platform === 'linux' ? { icon } : {}),
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set App Show Position
|
||||||
|
mainWindow.setPosition(width / 2, height / 2)
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
mainWindow?.show()
|
||||||
|
|
||||||
|
// 🚀 Mở DevTools khi sẵn sàng
|
||||||
|
mainWindow?.webContents.openDevTools({ mode: 'detach' })
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
|
shell.openExternal(details.url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Make the window always on top
|
||||||
|
mainWindow.setAlwaysOnTop(true, 'normal')
|
||||||
|
|
||||||
|
// mainWindow.webContents.openDevTools()
|
||||||
|
|
||||||
|
// Inspect element with shortcut
|
||||||
|
mainWindow.webContents.once('did-finish-load', () => {
|
||||||
|
const shortcut = 'Control+Shift+C'
|
||||||
|
|
||||||
|
mainWindow?.on('focus', () => {
|
||||||
|
globalShortcut.register(shortcut, () => {
|
||||||
|
const pos = screen.getCursorScreenPoint()
|
||||||
|
mainWindow?.webContents.inspectElement(pos.x, pos.y)
|
||||||
|
mainWindow?.webContents.focus() // optional: refocus back
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow?.on('blur', () => {
|
||||||
|
globalShortcut.unregister(shortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow?.on('closed', () => {
|
||||||
|
globalShortcut.unregister(shortcut)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// HMR for renderer base on electron-vite cli.
|
||||||
|
// Load the remote URL for development or the local html file for production.
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Khi bấm dấu X
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuiting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createTray() {
|
function createTray() {
|
||||||
const tray = new Tray(icon)
|
const tray = new Tray(icon)
|
||||||
|
|
||||||
const contextMenu = Menu.buildFromTemplate([
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Show',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore()
|
||||||
|
}
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
} else {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Quit',
|
label: 'Quit',
|
||||||
click: () => {
|
click: () => {
|
||||||
|
isQuiting = true
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,3 +163,57 @@ app.whenReady().then(() => {
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
unregisterGlobalShortcuts()
|
unregisterGlobalShortcuts()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// 📦 Ghi dữ liệu mới
|
||||||
|
const newData = jsonContent
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(newData, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
// Đăng ký lại global shortcuts
|
||||||
|
unregisterGlobalShortcuts()
|
||||||
|
registerGlobalShortcuts()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { globalShortcut, clipboard, shell } from 'electron'
|
|
||||||
|
|
||||||
export interface IShortcut {
|
|
||||||
shortcut: string
|
|
||||||
links: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Danh sách shortcut mẫu
|
|
||||||
const shortcuts: IShortcut[] = [
|
|
||||||
{
|
|
||||||
links: [
|
|
||||||
'https://int.ipsupply.com.au/erptools/001_search-vpn?search=${query}',
|
|
||||||
'https://www.ebay.com/sch/i.html?_nkw=${query}&_sop=15'
|
|
||||||
],
|
|
||||||
shortcut: 'CommandOrControl+Shift+1'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
links: ['https://esearch.danielvu.com?keyword=${query}'],
|
|
||||||
shortcut: 'CommandOrControl+Shift+2'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mở tất cả link gắn với shortcut và thay ${query} bằng clipboard text
|
|
||||||
*/
|
|
||||||
const handleOpenBrowserByLink = (data: IShortcut) => {
|
|
||||||
const text = clipboard.readText().trim()
|
|
||||||
|
|
||||||
if (!text) {
|
|
||||||
console.log(`[${new Date().toLocaleTimeString()}] Clipboard is empty.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = encodeURIComponent(text)
|
|
||||||
|
|
||||||
data.links.forEach((link) => {
|
|
||||||
const url = link.replace(/\$\{query\}/g, query) // Thay ${query} trong link
|
|
||||||
console.log(`[${new Date().toLocaleTimeString()}] Opening URL: ${url}`)
|
|
||||||
shell.openExternal(url) // Mở trong browser mặc định
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Đăng ký tất cả global shortcut
|
|
||||||
*/
|
|
||||||
export function registerGlobalShortcuts() {
|
|
||||||
shortcuts.forEach((sc) => {
|
|
||||||
const ret = globalShortcut.register(sc.shortcut, () => {
|
|
||||||
console.log(`🚀 Triggered shortcut: ${sc.shortcut}`)
|
|
||||||
handleOpenBrowserByLink(sc)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ret) {
|
|
||||||
console.log(`❌ Failed to register shortcut: ${sc.shortcut}`)
|
|
||||||
} else {
|
|
||||||
console.log(`✅ Registered shortcut: ${sc.shortcut}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Xóa tất cả global shortcut khi thoát app
|
|
||||||
*/
|
|
||||||
export function unregisterGlobalShortcuts() {
|
|
||||||
globalShortcut.unregisterAll()
|
|
||||||
console.log('ℹ️ All global shortcuts unregistered.')
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
// shotcut.ts
|
||||||
|
import { globalShortcut, clipboard, shell, app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
|
||||||
|
export interface IShortcut {
|
||||||
|
id: string
|
||||||
|
shortcut: string
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danh sách shortcut mặc định
|
||||||
|
export const defaultShortcuts: IShortcut[] = [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
links: [
|
||||||
|
'https://int.ipsupply.com.au/erptools/001_search-vpn?search=${keyword}',
|
||||||
|
'https://www.ebay.com/sch/i.html?_nkw=${keyword}&_sop=15'
|
||||||
|
],
|
||||||
|
shortcut: 'CommandOrControl+Shift+1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
links: ['https://esearch.danielvu.com?keyword=${keyword}'],
|
||||||
|
shortcut: 'CommandOrControl+Shift+2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Lấy đường dẫn file config trong thư mục userData
|
||||||
|
const getConfigFilePath = () => {
|
||||||
|
return path.join(app.getPath('userData'), 'config-data.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đảm bảo file config tồn tại
|
||||||
|
* Nếu chưa có, tạo mới với dữ liệu defaultShortcuts
|
||||||
|
*/
|
||||||
|
const ensureConfigFile = () => {
|
||||||
|
const filePath = getConfigFilePath()
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(defaultShortcuts, null, 2), 'utf-8')
|
||||||
|
console.log(`📝 Created default config file at: ${filePath}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to create default config file:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đọc danh sách shortcut từ file config
|
||||||
|
*/
|
||||||
|
const loadShortcutsFromConfig = (): IShortcut[] => {
|
||||||
|
const filePath = getConfigFilePath()
|
||||||
|
|
||||||
|
ensureConfigFile() // 🔹 Đảm bảo luôn có file trước khi đọc
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawData = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const shortcuts = JSON.parse(rawData) as IShortcut[]
|
||||||
|
|
||||||
|
if (!Array.isArray(shortcuts)) {
|
||||||
|
throw new Error('Config file format is invalid: must be an array')
|
||||||
|
}
|
||||||
|
|
||||||
|
return shortcuts
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Failed to read or parse config file:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mở tất cả link gắn với shortcut và thay ${query} bằng clipboard text
|
||||||
|
*/
|
||||||
|
const handleOpenBrowserByLink = (data: IShortcut) => {
|
||||||
|
const text = clipboard.readText().trim()
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Clipboard is empty.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = encodeURIComponent(text)
|
||||||
|
|
||||||
|
data.links.forEach((link) => {
|
||||||
|
const url = link.replace(/\$\{keyword\}/g, query) // Thay ${query} trong link
|
||||||
|
console.log(`[${new Date().toLocaleTimeString()}] Opening URL: ${url}`)
|
||||||
|
shell.openExternal(url) // Mở trong browser mặc định
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đăng ký tất cả global shortcut từ file config
|
||||||
|
*/
|
||||||
|
export function registerGlobalShortcuts() {
|
||||||
|
const shortcuts = loadShortcutsFromConfig()
|
||||||
|
|
||||||
|
if (shortcuts.length === 0) {
|
||||||
|
console.log('⚠️ No shortcuts found to register.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shortcuts.forEach((sc) => {
|
||||||
|
const ret = globalShortcut.register(sc.shortcut, () => {
|
||||||
|
console.log(`🚀 Triggered shortcut: ${sc.shortcut}`)
|
||||||
|
handleOpenBrowserByLink(sc)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ret) {
|
||||||
|
console.log(`❌ Failed to register shortcut: ${sc.shortcut}`)
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Registered shortcut: ${sc.shortcut}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xóa tất cả global shortcut khi thoát app
|
||||||
|
*/
|
||||||
|
export function unregisterGlobalShortcuts() {
|
||||||
|
globalShortcut.unregisterAll()
|
||||||
|
console.log('ℹ️ All global shortcuts unregistered.')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Shotcut</title>
|
||||||
|
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||||
|
<!-- <meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||||
|
/> -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Box, Container, LoadingOverlay, MantineProvider } from '@mantine/core'
|
||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
|
import { ShotcutForm } from './components/ShotcutForm'
|
||||||
|
import { ShotcutList } from './components/ShotcutList'
|
||||||
|
import { theme } from './theme'
|
||||||
|
import { IShotcut } from './types'
|
||||||
|
import { getConfigFile, saveConfigFile } from './api/config'
|
||||||
|
|
||||||
|
function App(): React.JSX.Element {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [shotcuts, setShotcuts] = useState<IShotcut[]>([])
|
||||||
|
|
||||||
|
// Hàm lưu config
|
||||||
|
const saveConfig = async (newShortcuts: IShotcut[]) => {
|
||||||
|
const result = await saveConfigFile(newShortcuts)
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Failed to save config:', result.error)
|
||||||
|
} else {
|
||||||
|
console.log('✅ Config saved to', result.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (shotcut: IShotcut) => {
|
||||||
|
const newShortcuts = shotcuts.filter((item) => item.id !== shotcut.id)
|
||||||
|
|
||||||
|
setShotcuts(newShortcuts)
|
||||||
|
saveConfig(newShortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gọi IPC để lấy dữ liệu config
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await getConfigFile()
|
||||||
|
console.log('Config data:', data)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setShotcuts(data)
|
||||||
|
} else {
|
||||||
|
setShotcuts([])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading config file:', error)
|
||||||
|
setShotcuts([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={true}
|
||||||
|
zIndex={1000}
|
||||||
|
overlayProps={{ radius: 'sm', blur: 1 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Container fluid mt={'lg'}>
|
||||||
|
<ShotcutForm
|
||||||
|
onSubmit={(newItem) => {
|
||||||
|
const updated = [newItem, ...shotcuts]
|
||||||
|
setShotcuts(updated)
|
||||||
|
saveConfig(updated)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box py={'lg'}>
|
||||||
|
<ShotcutList shotcuts={shotcuts} onDelete={handleDelete} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={loading}
|
||||||
|
zIndex={1000}
|
||||||
|
overlayProps={{ radius: 'sm', blur: 1 }}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</Suspense>
|
||||||
|
</MantineProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { IShotcut } from '@renderer/types'
|
||||||
|
|
||||||
|
// src/api/config.ts
|
||||||
|
export const getConfigFile = async () => {
|
||||||
|
return await window.electron.ipcRenderer.invoke('get-config-file')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveConfigFile = async (data: IShotcut[]) => {
|
||||||
|
return await window.electron.ipcRenderer.invoke('save-config-file', data)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
@keyframes borderPulse {
|
||||||
|
0% {
|
||||||
|
border-color: #e03131;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: black;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
border-color: #e03131;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
animation: borderPulse 1s infinite;
|
||||||
|
border: 4px solid #e03131;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card p {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-overflow-1-line {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-overflow-1-line:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-picture {
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.item-picture {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
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: 10, // default timeout 30s
|
||||||
|
delay_hide: 10
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
timeout: (value) => (value <= 0 ? 'Timeout must be greater than 0' : null),
|
||||||
|
delay_hide: (value) => (value <= 0 ? 'Delay 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,
|
||||||
|
delay_hide: form.values.delay_hide
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
form.setValues({ timeout: data?.noti_timeout, delay_hide: data.delay_hide })
|
||||||
|
} 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 (
|
||||||
|
<Modal title="Configuration" {...props}>
|
||||||
|
<form onSubmit={form.onSubmit(handleSave)}>
|
||||||
|
<Stack>
|
||||||
|
<NumberInput
|
||||||
|
label="Timeout for notification (seconds)"
|
||||||
|
placeholder="Enter timeout in seconds"
|
||||||
|
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
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ActionIcon, Button, Code, Group, Paper, Stack, Text, TextInput } from '@mantine/core'
|
||||||
|
import { useForm, zodResolver } from '@mantine/form'
|
||||||
|
import { IShotcut } from '@renderer/types'
|
||||||
|
import { IconPlus, IconX } from '@tabler/icons-react'
|
||||||
|
import { v4 as uuid } from 'uuid'
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
// Modifier keys
|
||||||
|
const MODIFIERS = ['Ctrl', 'Control', 'Cmd', 'Command', 'Alt', 'Option', 'Shift']
|
||||||
|
// Last key regex
|
||||||
|
const keyRegex = /^([A-Z0-9]|F[1-9]|F1[0-2]|Enter|Tab)$/i
|
||||||
|
|
||||||
|
// Zod schema
|
||||||
|
const ShotcutSchema = z.object({
|
||||||
|
shortcut: z
|
||||||
|
.string()
|
||||||
|
.nonempty('Shortcut is required')
|
||||||
|
.refine((val) => {
|
||||||
|
const parts = val.split('+').map((p) => p.trim())
|
||||||
|
if (parts.length < 2) return false
|
||||||
|
const last = parts[parts.length - 1]
|
||||||
|
const modifiers = parts.slice(0, -1)
|
||||||
|
return modifiers.every((m) => MODIFIERS.includes(m)) && keyRegex.test(last)
|
||||||
|
}, 'Invalid shortcut format. Example: Ctrl+Shift+1'),
|
||||||
|
links: z
|
||||||
|
.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.nonempty('Link cannot be empty')
|
||||||
|
.refine((val) => {
|
||||||
|
const temp = val.replaceAll('{{keyword}}', 'test')
|
||||||
|
try {
|
||||||
|
new URL(temp)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 'Invalid URL. Can include {{keyword}}')
|
||||||
|
)
|
||||||
|
.min(1, 'At least one link is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
type ShotcutFormValues = z.infer<typeof ShotcutSchema>
|
||||||
|
|
||||||
|
interface ShotcutFormProps {
|
||||||
|
onSubmit: (shotcut: IShotcut) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShotcutForm({ onSubmit }: ShotcutFormProps) {
|
||||||
|
const form = useForm<ShotcutFormValues>({
|
||||||
|
initialValues: { shortcut: '', links: [''] },
|
||||||
|
validate: zodResolver(ShotcutSchema)
|
||||||
|
})
|
||||||
|
|
||||||
|
const addLink = () => form.insertListItem('links', '')
|
||||||
|
const removeLink = (index: number) => form.removeListItem('links', index)
|
||||||
|
|
||||||
|
const handleSubmit = (values: ShotcutFormValues) => {
|
||||||
|
onSubmit({ ...values, id: uuid() })
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper shadow="sm" radius="md" p="md" withBorder maw={600} mx="auto">
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack>
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
Create New Shortcut
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Shortcut"
|
||||||
|
placeholder="Ctrl+Shift+1"
|
||||||
|
{...form.getInputProps('shortcut')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<Group align="center">
|
||||||
|
<Text size="sm">Links</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Use <Code>{'{keyword}'}</Code> as placeholder
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{form.values.links.map((link, index) => (
|
||||||
|
<Group key={index} align="center">
|
||||||
|
<TextInput
|
||||||
|
placeholder="https://google.com/search?q={keyword}"
|
||||||
|
{...form.getInputProps(`links.${index}`)}
|
||||||
|
size="sm"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
{form.values.links.length > 1 && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeLink(index)}
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addLink}
|
||||||
|
size="sm"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
>
|
||||||
|
Add Link
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Create Shortcut
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ActionIcon, Badge, Card, Group, Stack, Text, Modal, Button, Flex } from '@mantine/core'
|
||||||
|
import { IShotcut } from '@renderer/types'
|
||||||
|
import { IconExternalLink, IconTrash } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
interface ShotcutListProps {
|
||||||
|
shotcuts: IShotcut[]
|
||||||
|
onDelete: (shotcut: IShotcut) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShotcutList({ shotcuts, onDelete }: ShotcutListProps) {
|
||||||
|
const [opened, setOpened] = useState(false)
|
||||||
|
const [selectedShotcut, setSelectedShotcut] = useState<IShotcut | null>(null)
|
||||||
|
|
||||||
|
const openConfirmModal = (shotcut: IShotcut) => {
|
||||||
|
setSelectedShotcut(shotcut)
|
||||||
|
setOpened(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDelete = () => {
|
||||||
|
if (selectedShotcut) {
|
||||||
|
onDelete(selectedShotcut)
|
||||||
|
setSelectedShotcut(null)
|
||||||
|
setOpened(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shotcuts.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" p="md" withBorder>
|
||||||
|
<Text ta="center" c="dimmed">
|
||||||
|
No shortcuts yet. Create your first shortcut!
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text size="xl" fw={600}>
|
||||||
|
Shortcut List
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{shotcuts.map((shotcut, index) => (
|
||||||
|
<Card key={index} shadow="sm" radius="md" p="md" withBorder>
|
||||||
|
{/* Header */}
|
||||||
|
<Group mb="sm">
|
||||||
|
<Text size="lg" fw={500}>
|
||||||
|
{shotcut.shortcut}
|
||||||
|
</Text>
|
||||||
|
<ActionIcon
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => openConfirmModal(shotcut)}
|
||||||
|
title="Delete shortcut"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{shotcut.links.length} link(s)
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Link badges */}
|
||||||
|
<Group>
|
||||||
|
{shotcut.links.map((link, linkIndex) => (
|
||||||
|
<Badge
|
||||||
|
key={linkIndex}
|
||||||
|
variant="outline"
|
||||||
|
radius="sm"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => window.open(link, '_blank')}
|
||||||
|
leftSection={<IconExternalLink size={14} />}
|
||||||
|
>
|
||||||
|
{link}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Confirm Modal */}
|
||||||
|
<Modal opened={opened} onClose={() => setOpened(false)} title="Confirm Delete" centered>
|
||||||
|
<Text mb="md">
|
||||||
|
Are you sure you want to delete shortcut{' '}
|
||||||
|
<strong>{selectedShotcut?.shortcut}</strong>?
|
||||||
|
</Text>
|
||||||
|
<Flex justify={'flex-end'} gap={'sm'}>
|
||||||
|
<Button variant="default" onClick={() => setOpened(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={handleConfirmDelete}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
disableSound: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import Pusher from 'pusher-js'
|
||||||
|
|
||||||
|
export const pusher = new Pusher(import.meta.env.VITE_PUSHER_APP_KEY, {
|
||||||
|
cluster: 'ap4'
|
||||||
|
// encrypted: true
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createTheme } from '@mantine/core'
|
||||||
|
import { themeToVars } from '@mantine/vanilla-extract'
|
||||||
|
|
||||||
|
export const theme = createTheme({})
|
||||||
|
export const vars = themeToVars(theme)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface IShotcut {
|
||||||
|
id: string
|
||||||
|
shortcut: string
|
||||||
|
links: string[]
|
||||||
|
}
|
||||||
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue