update save config
This commit is contained in:
parent
72f62d4cc5
commit
ed00ebd174
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "shotcut-app",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shotcut-app",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"electron-updater": "^6.3.9",
|
||||
"moment": "^2.30.1",
|
||||
"pusher-js": "^8.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"windows-shortcuts": "^0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -10726,6 +10727,19 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"electron-updater": "^6.3.9",
|
||||
"moment": "^2.30.1",
|
||||
"pusher-js": "^8.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"windows-shortcuts": "^0.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,114 @@
|
|||
/* eslint-disable prettier/prettier */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, ipcMain, Menu, Tray } from 'electron'
|
||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, globalShortcut, ipcMain, Menu, Tray, screen, shell } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import path, { join } from 'path'
|
||||
import ws from 'windows-shortcuts'
|
||||
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() {
|
||||
const tray = new Tray(icon)
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
createWindow()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
isQuiting = true
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
|
|
@ -69,3 +163,57 @@ app.whenReady().then(() => {
|
|||
app.on('will-quit', () => {
|
||||
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