🐛 Fix(custom): fix drag upload of upload page

This commit is contained in:
Kuingsmile
2025-08-11 14:59:25 +08:00
parent 112c08d92c
commit 0bf435a0da
10 changed files with 4189 additions and 4200 deletions

View File

@@ -1,277 +1,277 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import bus from '@core/bus'
import { CREATE_APP_MENU } from '@core/bus/constants'
import db from '@core/datastore'
import { app, BrowserWindow, Rectangle } from 'electron'
import type { IWindowListItem } from '#/types/electron'
import type { IBrowserWindowOptions } from '#/types/types'
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '~/events/constant'
import { T as $t } from '~/i18n'
import { configPaths } from '~/utils/configPaths'
import { IWindowList } from '~/utils/enum'
import logo from '../../../../../resources/logo.png?asset'
const windowList = new Map<string, IWindowListItem>()
const getDefaultWindowSizes = (): { width: number; height: number } => {
const [mainWindowWidth, mainWindowHeight] = db.get([
configPaths.settings.mainWindowWidth,
configPaths.settings.mainWindowHeight
])
return {
width: mainWindowWidth || 1200,
height: mainWindowHeight || 800
}
}
function setMiniWindowShape (win: BrowserWindow) {
const radius = 32
const shape: Rectangle[] = []
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
if (x * x + y * y <= radius * radius) {
shape.push({ x: radius + x, y: radius + y, width: 1, height: 1 })
}
}
}
win.setShape(shape)
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const preloadPath = fileURLToPath(new URL('../preload/index.mjs', import.meta.url))
const { width: defaultWindowWidth, height: defaultWindowHeight } = getDefaultWindowSizes()
const trayWindowOptions = {
height: 350,
width: 196,
show: false,
frame: false,
fullscreenable: false,
resizable: false,
transparent: true,
vibrancy: 'ultra-dark',
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
backgroundThrottling: false,
webSecurity: false
}
}
const settingWindowOptions = {
height: defaultWindowHeight,
width: defaultWindowWidth,
show: false,
frame: true,
center: true,
fullscreenable: true,
resizable: true,
title: 'PicList',
transparent: false,
backgroundColor: '#ebeef5',
titleBarStyle: 'hidden',
webPreferences: {
sandbox: false,
webviewTag: true,
backgroundThrottling: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
webSecurity: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
settingWindowOptions.frame = false
settingWindowOptions.icon = '../../../../../resources/logo.png'
}
const miniWindowOptions = {
height: 64,
width: 64,
show: process.platform === 'linux',
frame: false,
fullscreenable: false,
skipTaskbar: true,
resizable: false,
transparent: process.platform !== 'linux',
icon: logo,
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
nodeIntegrationInWorker: true
}
} as IBrowserWindowOptions
if (db.get(configPaths.settings.miniWindowOntop)) {
miniWindowOptions.alwaysOnTop = true
}
const renameWindowOptions = {
height: 250,
width: 350,
show: true,
fullscreenable: false,
icon: logo,
resizable: true,
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
backgroundThrottling: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
renameWindowOptions.show = true
renameWindowOptions.backgroundColor = '#3f3c37'
renameWindowOptions.autoHideMenuBar = true
renameWindowOptions.transparent = false
}
const toolboxWindowOptions = {
height: 450,
width: 800,
show: false,
frame: true,
center: true,
fullscreenable: false,
resizable: false,
title: `PicList ${$t('TOOLBOX')}`,
backgroundColor: '#ebeef5',
icon: logo,
webPreferences: {
sandbox: false,
backgroundThrottling: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
webSecurity: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
toolboxWindowOptions.backgroundColor = '#3f3c37'
toolboxWindowOptions.autoHideMenuBar = true
toolboxWindowOptions.transparent = false
}
windowList.set(IWindowList.TRAY_WINDOW, {
isValid: process.platform !== 'linux',
multiple: false,
options: () => trayWindowOptions,
callback (window) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'))
}
window.on('blur', () => {
window.hide()
})
}
})
windowList.set(IWindowList.SETTING_WINDOW, {
isValid: true,
multiple: false,
options: () => settingWindowOptions,
callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#main-page/upload`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'main-page/upload'
})
}
window.on('closed', () => {
bus.emit(TOGGLE_SHORTKEY_MODIFIED_MODE, false)
if (process.platform === 'linux') {
process.nextTick(() => {
app.quit()
})
}
})
bus.emit(CREATE_APP_MENU)
windowManager.create(IWindowList.MINI_WINDOW)
}
})
windowList.set(IWindowList.MINI_WINDOW, {
isValid: process.platform !== 'darwin',
multiple: false,
options: () => miniWindowOptions,
callback (window) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#mini-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'mini-page'
})
}
setMiniWindowShape(window)
}
})
windowList.set(IWindowList.RENAME_WINDOW, {
isValid: true,
multiple: true,
options: () => renameWindowOptions,
async callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#rename-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'rename-page'
})
}
const currentWindow = windowManager.getAvailableWindow(true)
if (currentWindow && currentWindow.isVisible()) {
const { x, y, width, height } = currentWindow.getBounds()
const positionX = Math.floor(x + width / 2 - 150)
const positionY = Math.floor(y + height / 2 - (height > 400 ? 88 : 0))
window.setPosition(positionX, positionY, false)
}
}
})
windowList.set(IWindowList.TOOLBOX_WINDOW, {
isValid: true,
multiple: false,
options: () => toolboxWindowOptions,
async callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#toolbox-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'toolbox-page'
})
}
const currentWindow = windowManager.getAvailableWindow(true)
if (currentWindow && currentWindow.isVisible()) {
const { x, y, width, height } = currentWindow.getBounds()
const positionX = Math.floor(x + width / 2 - 400)
const positionY = Math.floor(y + height / 2 - (height > 400 ? 225 : 0))
window.setPosition(positionX, positionY, false)
}
}
})
export default windowList
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import bus from '@core/bus'
import { CREATE_APP_MENU } from '@core/bus/constants'
import db from '@core/datastore'
import { app, BrowserWindow, Rectangle } from 'electron'
import type { IWindowListItem } from '#/types/electron'
import type { IBrowserWindowOptions } from '#/types/types'
import { TOGGLE_SHORTKEY_MODIFIED_MODE } from '~/events/constant'
import { T as $t } from '~/i18n'
import { configPaths } from '~/utils/configPaths'
import { IWindowList } from '~/utils/enum'
import logo from '../../../../../resources/logo.png?asset'
const windowList = new Map<string, IWindowListItem>()
const getDefaultWindowSizes = (): { width: number; height: number } => {
const [mainWindowWidth, mainWindowHeight] = db.get([
configPaths.settings.mainWindowWidth,
configPaths.settings.mainWindowHeight
])
return {
width: mainWindowWidth || 1200,
height: mainWindowHeight || 800
}
}
function setMiniWindowShape (win: BrowserWindow) {
const radius = 32
const shape: Rectangle[] = []
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
if (x * x + y * y <= radius * radius) {
shape.push({ x: radius + x, y: radius + y, width: 1, height: 1 })
}
}
}
win.setShape(shape)
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const preloadPath = fileURLToPath(new URL('../preload/index.mjs', import.meta.url))
const { width: defaultWindowWidth, height: defaultWindowHeight } = getDefaultWindowSizes()
const trayWindowOptions = {
height: 350,
width: 196,
show: false,
frame: false,
fullscreenable: false,
resizable: false,
transparent: true,
vibrancy: 'ultra-dark',
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
backgroundThrottling: false,
webSecurity: false
}
}
const settingWindowOptions = {
height: defaultWindowHeight,
width: defaultWindowWidth,
show: false,
frame: true,
center: true,
fullscreenable: true,
resizable: true,
title: 'PicList',
transparent: false,
backgroundColor: '#ebeef5',
titleBarStyle: 'hidden',
webPreferences: {
sandbox: false,
webviewTag: true,
backgroundThrottling: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
webSecurity: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
settingWindowOptions.frame = false
settingWindowOptions.icon = '../../../../../resources/logo.png'
}
const miniWindowOptions = {
height: 64,
width: 64,
show: process.platform === 'linux',
frame: false,
fullscreenable: false,
skipTaskbar: true,
resizable: false,
transparent: process.platform !== 'linux',
icon: logo,
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
backgroundThrottling: false,
nodeIntegrationInWorker: true
}
} as IBrowserWindowOptions
if (db.get(configPaths.settings.miniWindowOntop)) {
miniWindowOptions.alwaysOnTop = true
}
const renameWindowOptions = {
height: 270,
width: 350,
show: true,
fullscreenable: false,
icon: logo,
resizable: true,
webPreferences: {
sandbox: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
backgroundThrottling: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
renameWindowOptions.show = true
renameWindowOptions.backgroundColor = '#3f3c37'
renameWindowOptions.autoHideMenuBar = true
renameWindowOptions.transparent = false
}
const toolboxWindowOptions = {
height: 450,
width: 800,
show: false,
frame: true,
center: true,
fullscreenable: false,
resizable: false,
title: `PicList ${$t('TOOLBOX')}`,
backgroundColor: '#ebeef5',
icon: logo,
webPreferences: {
sandbox: false,
backgroundThrottling: false,
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
nodeIntegrationInWorker: true,
webSecurity: false
}
} as IBrowserWindowOptions
if (process.platform !== 'darwin') {
toolboxWindowOptions.backgroundColor = '#3f3c37'
toolboxWindowOptions.autoHideMenuBar = true
toolboxWindowOptions.transparent = false
}
windowList.set(IWindowList.TRAY_WINDOW, {
isValid: process.platform !== 'linux',
multiple: false,
options: () => trayWindowOptions,
callback (window) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'))
}
window.on('blur', () => {
window.hide()
})
}
})
windowList.set(IWindowList.SETTING_WINDOW, {
isValid: true,
multiple: false,
options: () => settingWindowOptions,
callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#main-page/upload`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'main-page/upload'
})
}
window.on('closed', () => {
bus.emit(TOGGLE_SHORTKEY_MODIFIED_MODE, false)
if (process.platform === 'linux') {
process.nextTick(() => {
app.quit()
})
}
})
bus.emit(CREATE_APP_MENU)
windowManager.create(IWindowList.MINI_WINDOW)
}
})
windowList.set(IWindowList.MINI_WINDOW, {
isValid: process.platform !== 'darwin',
multiple: false,
options: () => miniWindowOptions,
callback (window) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#mini-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'mini-page'
})
}
setMiniWindowShape(window)
}
})
windowList.set(IWindowList.RENAME_WINDOW, {
isValid: true,
multiple: true,
options: () => renameWindowOptions,
async callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#rename-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'rename-page'
})
}
const currentWindow = windowManager.getAvailableWindow(true)
if (currentWindow && currentWindow.isVisible()) {
const { x, y, width, height } = currentWindow.getBounds()
const positionX = Math.floor(x + width / 2 - 150)
const positionY = Math.floor(y + height / 2 - (height > 400 ? 88 : 0))
window.setPosition(positionX, positionY, false)
}
}
})
windowList.set(IWindowList.TOOLBOX_WINDOW, {
isValid: true,
multiple: false,
options: () => toolboxWindowOptions,
async callback (window, windowManager) {
if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) {
window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#toolbox-page`)
} else {
window.loadFile(path.join(__dirname, '../render/index.html'), {
hash: 'toolbox-page'
})
}
const currentWindow = windowManager.getAvailableWindow(true)
if (currentWindow && currentWindow.isVisible()) {
const { x, y, width, height } = currentWindow.getBounds()
const positionX = Math.floor(x + width / 2 - 400)
const positionY = Math.floor(y + height / 2 - (height > 400 ? 225 : 0))
window.setPosition(positionX, positionY, false)
}
}
})
export default windowList

View File

@@ -1,335 +1,324 @@
import path from 'node:path'
import db from '@core/datastore'
import logger from '@core/picgo/logger'
import axios from 'axios'
import { clipboard, dialog, Notification, Tray } from 'electron'
import FormData from 'form-data'
import fs from 'fs-extra'
import { isReactive, isRef, toRaw, unref } from 'vue'
import type { IHTTPProxy, IPrivateShowNotificationOption, IShowMessageBoxResult, IStringKeyMap } from '#/types/types'
import { configPaths } from '~/utils/configPaths'
import { IShortUrlServer } from '~/utils/enum'
/**
* get raw data from reactive or ref
*/
export const getRawData = (args: any): any => {
if (isRef(args)) return unref(args)
if (isReactive(args)) return toRaw(args)
if (Array.isArray(args)) return args.map(getRawData)
if (typeof args === 'object' && args !== null) {
const data = {} as Record<string, any>
for (const key in args) {
data[key] = getRawData(args[key])
}
return data
}
return args
}
const getExtension = (fileName: string) => path.extname(fileName).slice(1)
export const isImage = (fileName: string) =>
['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif'].includes(getExtension(fileName))
export let tray: Tray
export const setTray = (t: Tray) => {
tray = t
}
export const getTray = () => tray
export function setTrayToolTip (title: string): void {
if (tray) {
tray.setToolTip(title)
}
}
export const handleCopyUrl = (str: string): void => {
if (db.get(configPaths.settings.autoCopy) !== false) {
clipboard.writeText(str)
}
}
/**
* show notification
* @param options
*/
export const showNotification = (
options: IPrivateShowNotificationOption = {
title: '',
body: '',
clickToCopy: false,
copyContent: '',
clickFn: () => {}
}
) => {
const notification = new Notification({
title: options.title,
body: options.body
// icon: options.icon || undefined
})
const handleClick = () => {
if (options.clickToCopy) {
clipboard.writeText(options.copyContent || options.body)
}
if (options.clickFn) {
options.clickFn()
}
}
notification.once('click', handleClick)
notification.once('close', () => {
notification.removeListener('click', handleClick)
})
notification.show()
}
export const showMessageBox = (options: any) => {
return new Promise<IShowMessageBoxResult>(resolve => {
dialog.showMessageBox(options).then(res => {
resolve({
result: res.response,
checkboxChecked: res.checkboxChecked
})
})
})
}
/**
* macOS public.file-url will get encoded file path,
* so we need to decode it
*/
export const ensureFilePath = (filePath: string, prefix = 'file://'): string => {
filePath = filePath.replace(prefix, '')
if (fs.existsSync(filePath)) {
return `${prefix}${filePath}`
}
filePath = decodeURIComponent(filePath)
if (fs.existsSync(filePath)) {
return `${prefix}${filePath}`
}
return ''
}
/**
* for builtin clipboard to get image path from clipboard
* @returns
*/
export const getClipboardFilePath = (): string => {
// TODO: linux support
const img = clipboard.readImage()
const platform = process.platform
if (!img.isEmpty() && platform === 'darwin') {
let imgPath = clipboard.read('public.file-url') // will get file://xxx/xxx
imgPath = ensureFilePath(imgPath)
return imgPath ? imgPath.replace('file://', '') : ''
}
if (img.isEmpty() && platform === 'win32') {
const imgPath = clipboard
.readBuffer('FileNameW')
?.toString('ucs2')
?.replace(RegExp(String.fromCharCode(0), 'g'), '')
return imgPath || ''
}
return ''
}
const c1nApi = 'https://c1n.cn/link/short'
const generateC1NShortUrl = async (url: string) => {
const c1nToken = db.get(configPaths.settings.c1nToken) || ''
if (!c1nToken) {
logger.warn('c1n token is not set')
return url
}
try {
const form = new FormData()
form.append('url', url)
const res = await axios.post(c1nApi, form, {
headers: {
token: c1nToken
}
})
if (res.status >= 200 && res.status < 300 && res.data?.code === 0) {
return res.data.data
}
} catch (e: any) {
logger.error(e)
}
return url
}
const generateYOURLSShortUrl = async (url: string) => {
let domain = db.get(configPaths.settings.yourlsDomain) || ''
const signature = db.get(configPaths.settings.yourlsSignature) || ''
if (!domain || !signature) {
logger.warn('Yourls server or signature is not set')
return url
}
if (!/^https?:\/\//.test(domain)) {
domain = `http://${domain}`
}
const params = new URLSearchParams({
signature,
action: 'shorturl',
format: 'json',
url
})
try {
const res = await axios.get(`${domain}/yourls-api.php?${params.toString()}`)
if (res.data?.shorturl) {
return res.data.shorturl
}
} catch (e: any) {
if (e.response?.data?.message?.includes('already exists in database')) {
return e.response.data.shorturl
}
logger.error(e)
}
return url
}
const generateCFWORKERShortUrl = async (url: string) => {
let cfWorkerHost = db.get(configPaths.settings.cfWorkerHost) || ''
cfWorkerHost = cfWorkerHost.replace(/\/$/, '')
if (!cfWorkerHost) {
logger.warn('CF Worker host is not set')
return url
}
try {
const res = await axios.post(cfWorkerHost, { url })
if (res.data?.status === 200 && res.data?.key?.startsWith('/')) {
return `${cfWorkerHost}${res.data.key}`
}
} catch (e: any) {
logger.error(e)
}
return url
}
const generateSinkShortUrl = async (url: string) => {
let sinkDomain = db.get(configPaths.settings.sinkDomain) || ''
const sinkToken = db.get(configPaths.settings.sinkToken) || ''
if (!sinkDomain || !sinkToken) {
logger.warn('Sink domain or token is not set')
return url
}
if (!/^https?:\/\//.test(sinkDomain)) {
sinkDomain = `http://${sinkDomain}`
}
if (sinkDomain.endsWith('/')) {
sinkDomain = sinkDomain.slice(0, -1)
}
try {
const res = await axios.post(
`${sinkDomain}/api/link/create`,
{ url },
{ headers: { Authorization: `Bearer ${sinkToken}` } }
)
if (res.data?.link?.slug) {
return `${sinkDomain}/${res.data.link.slug}`
}
} catch (e: any) {
logger.error(e)
}
return url
}
export const generateShortUrl = async (url: string) => {
const server = db.get(configPaths.settings.shortUrlServer) || IShortUrlServer.C1N
switch (server) {
case IShortUrlServer.C1N:
return generateC1NShortUrl(url)
case IShortUrlServer.YOURLS:
return generateYOURLSShortUrl(url)
case IShortUrlServer.CFWORKER:
return generateCFWORKERShortUrl(url)
case IShortUrlServer.SINK:
return generateSinkShortUrl(url)
default:
return url
}
}
export const isUrl = (url: string): boolean => {
try {
return Boolean(new URL(url))
} catch {
return false
}
}
export const isUrlEncode = (url: string): boolean => {
url = url || ''
try {
return url !== decodeURI(url)
} catch {
return false
}
}
export const handleUrlEncode = (url: string): string => (isUrlEncode(url) ? url : encodeURI(url))
export const handleUrlEncodeWithSetting = (url: string) =>
db.get(configPaths.settings.encodeOutputURL) ? handleUrlEncode(url) : url
export const handleStreamlinePluginName = (name: string) => name.replace(/(@[^/]+\/)?picgo-plugin-/, '')
export const simpleClone = (obj: any) => JSON.parse(JSON.stringify(obj))
export const enforceNumber = (num: number | string) => (isNaN(+num) ? 0 : +num)
export const trimValues = <T extends IStringKeyMap>(
obj: T
): { [K in keyof T]: T[K] extends string ? string : T[K] } => {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
) as { [K in keyof T]: T[K] extends string ? string : T[K] }
}
export const formatEndpoint = (endpoint: string, sslEnabled: boolean): string => {
const hasProtocol = /^https?:\/\//.test(endpoint)
if (!hasProtocol) {
return `${sslEnabled ? 'https' : 'http'}://${endpoint}`
}
return sslEnabled ? endpoint.replace(/^http:\/\//, 'https://') : endpoint.replace(/^https:\/\//, 'http://')
}
export const formatHttpProxy = (
proxy: string | undefined,
type: 'object' | 'string'
): IHTTPProxy | undefined | string => {
if (!proxy) return undefined
if (/^https?:\/\//.test(proxy)) {
const { protocol, hostname, port } = new URL(proxy)
return type === 'string'
? `${protocol}//${hostname}:${port}`
: {
host: hostname,
port: Number(port),
protocol: protocol.slice(0, -1)
}
}
const [host, port] = proxy.split(':')
return type === 'string'
? `http://${host}:${port}`
: {
host,
port: port ? Number(port) : 80,
protocol: 'http'
}
}
export function encodeFilePath (filePath: string) {
return filePath.replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/')
}
export const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/')
import path from 'node:path'
import db from '@core/datastore'
import logger from '@core/picgo/logger'
import axios from 'axios'
import { clipboard, Notification, Tray } from 'electron'
import FormData from 'form-data'
import fs from 'fs-extra'
import { isReactive, isRef, toRaw, unref } from 'vue'
import type { IHTTPProxy, IPrivateShowNotificationOption, IStringKeyMap } from '#/types/types'
import { configPaths } from '~/utils/configPaths'
import { IShortUrlServer } from '~/utils/enum'
/**
* get raw data from reactive or ref
*/
export const getRawData = (args: any): any => {
if (isRef(args)) return unref(args)
if (isReactive(args)) return toRaw(args)
if (Array.isArray(args)) return args.map(getRawData)
if (typeof args === 'object' && args !== null) {
const data = {} as Record<string, any>
for (const key in args) {
data[key] = getRawData(args[key])
}
return data
}
return args
}
const getExtension = (fileName: string) => path.extname(fileName).slice(1)
export const isImage = (fileName: string) =>
['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif'].includes(getExtension(fileName))
export let tray: Tray
export const setTray = (t: Tray) => {
tray = t
}
export const getTray = () => tray
export function setTrayToolTip (title: string): void {
if (tray) {
tray.setToolTip(title)
}
}
export const handleCopyUrl = (str: string): void => {
if (db.get(configPaths.settings.autoCopy) !== false) {
clipboard.writeText(str)
}
}
/**
* show notification
* @param options
*/
export const showNotification = (
options: IPrivateShowNotificationOption = {
title: '',
body: '',
clickToCopy: false,
copyContent: '',
clickFn: () => {}
}
) => {
const notification = new Notification({
title: options.title,
body: options.body
// icon: options.icon || undefined
})
const handleClick = () => {
if (options.clickToCopy) {
clipboard.writeText(options.copyContent || options.body)
}
if (options.clickFn) {
options.clickFn()
}
}
notification.once('click', handleClick)
notification.once('close', () => {
notification.removeListener('click', handleClick)
})
notification.show()
}
/**
* macOS public.file-url will get encoded file path,
* so we need to decode it
*/
export const ensureFilePath = (filePath: string, prefix = 'file://'): string => {
filePath = filePath.replace(prefix, '')
if (fs.existsSync(filePath)) {
return `${prefix}${filePath}`
}
filePath = decodeURIComponent(filePath)
if (fs.existsSync(filePath)) {
return `${prefix}${filePath}`
}
return ''
}
/**
* for builtin clipboard to get image path from clipboard
* @returns
*/
export const getClipboardFilePath = (): string => {
// TODO: linux support
const img = clipboard.readImage()
const platform = process.platform
if (!img.isEmpty() && platform === 'darwin') {
let imgPath = clipboard.read('public.file-url') // will get file://xxx/xxx
imgPath = ensureFilePath(imgPath)
return imgPath ? imgPath.replace('file://', '') : ''
}
if (img.isEmpty() && platform === 'win32') {
const imgPath = clipboard
.readBuffer('FileNameW')
?.toString('ucs2')
?.replace(RegExp(String.fromCharCode(0), 'g'), '')
return imgPath || ''
}
return ''
}
const c1nApi = 'https://c1n.cn/link/short'
const createC1NShortUrl = async (url: string) => {
const c1nToken = db.get(configPaths.settings.c1nToken) || ''
if (!c1nToken) {
logger.warn('c1n token is not set')
return url
}
try {
const form = new FormData()
form.append('url', url)
const res = await axios.post(c1nApi, form, {
headers: {
token: c1nToken
}
})
if (res.status >= 200 && res.status < 300 && res.data?.code === 0) {
return res.data.data
}
} catch (e: any) {
logger.error(e)
}
return url
}
const createYOURLSShortLink = async (url: string) => {
let domain = db.get(configPaths.settings.yourlsDomain) || ''
const signature = db.get(configPaths.settings.yourlsSignature) || ''
if (!domain || !signature) {
logger.warn('Yourls server or signature is not set')
return url
}
if (!/^https?:\/\//.test(domain)) {
domain = `http://${domain}`
}
const params = new URLSearchParams({
signature,
action: 'shorturl',
format: 'json',
url
})
try {
const res = await axios.get(`${domain}/yourls-api.php?${params.toString()}`)
if (res.data?.shorturl) {
return res.data.shorturl
}
} catch (e: any) {
if (e.response?.data?.message?.includes('already exists in database')) {
return e.response.data.shorturl
}
logger.error(e)
}
return url
}
const createShortUrlForCFWorker = async (url: string) => {
let cfWorkerHost = db.get(configPaths.settings.cfWorkerHost) || ''
cfWorkerHost = cfWorkerHost.replace(/\/$/, '')
if (!cfWorkerHost) {
logger.warn('CF Worker host is not set')
return url
}
try {
const res = await axios.post(cfWorkerHost, { url })
if (res.data?.status === 200 && res.data?.key?.startsWith('/')) {
return `${cfWorkerHost}${res.data.key}`
}
} catch (e: any) {
logger.error(e)
}
return url
}
const createShortUrlFromSink = async (url: string) => {
let sinkDomain = db.get(configPaths.settings.sinkDomain) || ''
const sinkToken = db.get(configPaths.settings.sinkToken) || ''
if (!sinkDomain || !sinkToken) {
logger.warn('Sink domain or token is not set')
return url
}
if (!/^https?:\/\//.test(sinkDomain)) {
sinkDomain = `http://${sinkDomain}`
}
if (sinkDomain.endsWith('/')) {
sinkDomain = sinkDomain.slice(0, -1)
}
try {
const res = await axios.post(
`${sinkDomain}/api/link/create`,
{ url },
{ headers: { Authorization: `Bearer ${sinkToken}` } }
)
if (res.data?.link?.slug) {
return `${sinkDomain}/${res.data.link.slug}`
}
} catch (e: any) {
logger.error(e)
}
return url
}
export const generateShortUrl = async (url: string) => {
const server = db.get(configPaths.settings.shortUrlServer) || IShortUrlServer.C1N
switch (server) {
case IShortUrlServer.C1N:
return createC1NShortUrl(url)
case IShortUrlServer.YOURLS:
return createYOURLSShortLink(url)
case IShortUrlServer.CFWORKER:
return createShortUrlForCFWorker(url)
case IShortUrlServer.SINK:
return createShortUrlFromSink(url)
default:
return url
}
}
export const isUrl = (url: string): boolean => {
try {
return Boolean(new URL(url))
} catch {
return false
}
}
export const isUrlEncode = (url: string): boolean => {
url = url || ''
try {
return url !== decodeURI(url)
} catch {
return false
}
}
export const handleUrlEncode = (url: string): string => (isUrlEncode(url) ? url : encodeURI(url))
export const handleUrlEncodeWithSetting = (url: string) =>
db.get(configPaths.settings.encodeOutputURL) ? handleUrlEncode(url) : url
export const handleStreamlinePluginName = (name: string) => name.replace(/(@[^/]+\/)?picgo-plugin-/, '')
export const simpleClone = (obj: any) => JSON.parse(JSON.stringify(obj))
export const enforceNumber = (num: number | string) => (isNaN(+num) ? 0 : +num)
export const trimValues = <T extends IStringKeyMap>(
obj: T
): { [K in keyof T]: T[K] extends string ? string : T[K] } => {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
) as { [K in keyof T]: T[K] extends string ? string : T[K] }
}
export const formatEndpoint = (endpoint: string, sslEnabled: boolean): string => {
const hasProtocol = /^https?:\/\//.test(endpoint)
if (!hasProtocol) {
return `${sslEnabled ? 'https' : 'http'}://${endpoint}`
}
return sslEnabled ? endpoint.replace(/^http:\/\//, 'https://') : endpoint.replace(/^https:\/\//, 'http://')
}
export const formatHttpProxy = (
proxy: string | undefined,
type: 'object' | 'string'
): IHTTPProxy | undefined | string => {
if (!proxy) return undefined
if (/^https?:\/\//.test(proxy)) {
const { protocol, hostname, port } = new URL(proxy)
return type === 'string'
? `${protocol}//${hostname}:${port}`
: {
host: hostname,
port: Number(port),
protocol: protocol.slice(0, -1)
}
}
const [host, port] = proxy.split(':')
return type === 'string'
? `http://${host}:${port}`
: {
host,
port: port ? Number(port) : 80,
protocol: 'http'
}
}
export function encodeFilePath (filePath: string) {
return filePath.replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/')
}
export const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, '').replace(/\/+/g, '/')

View File

@@ -1,262 +1,263 @@
import crypto from 'node:crypto'
import http, { AgentOptions } from 'node:http'
import https from 'node:https'
import path from 'node:path'
import querystring from 'node:querystring'
import { DeleteObjectCommand, S3Client, S3ClientConfig } from '@aws-sdk/client-s3'
import { NodeHttpHandler } from '@smithy/node-http-handler'
import axios from 'axios'
import { ISftpPlistConfig } from 'piclist'
import type { IObj, IStringKeyMap } from '#/types/types'
import { getAgent } from '~/manage/utils/common'
import SSHClient from '~/utils/sshClient'
interface DogecloudTokenFull {
Credentials: {
accessKeyId: string
secretAccessKey: string
sessionToken: string
}
ExpiredAt: number
Buckets: {
name: string
s3Bucket: string
s3Endpoint: string
}[]
}
const dogeRegionMap: IStringKeyMap = {
'ap-shanghai': '0',
'ap-beijing': '1',
'ap-guangzhou': '2',
'ap-chengdu': '3'
}
async function dogecloudApi (
apiPath: string,
data = {},
jsonMode: boolean = false,
accessKey: string,
secretKey: string
) {
const body = jsonMode ? JSON.stringify(data) : querystring.encode(data)
const sign = crypto
.createHmac('sha1', secretKey)
.update(Buffer.from(apiPath + '\n' + body, 'utf8'))
.digest('hex')
const authorization = `TOKEN ${accessKey}:${sign}`
try {
const res = await axios.request({
url: `https://api.dogecloud.com${apiPath}`,
method: 'POST',
data: body,
responseType: 'json',
headers: {
'Content-Type': jsonMode ? 'application/json' : 'application/x-www-form-urlencoded',
Authorization: authorization
}
})
if (res.data.code !== 200) {
throw new Error('API Error')
}
return res.data.data
} catch (err: any) {
throw new Error('API Error')
}
}
async function getDogeToken (accessKey: string, secretKey: string): Promise<IObj | DogecloudTokenFull> {
try {
const data = await dogecloudApi(
'/auth/tmp_token.json',
{
channel: 'OSS_FULL',
scopes: ['*']
},
true,
accessKey,
secretKey
)
return data
} catch (err: any) {
console.log(err)
return {}
}
}
export async function removeFileFromS3InMain (configMap: IStringKeyMap, dogeMode: boolean = false) {
try {
const {
url: rawUrl,
type,
config: { accessKeyID, secretAccessKey, bucketName, endpoint, pathStyleAccess, rejectUnauthorized, proxy }
} = configMap
let {
imgUrl,
config: { region }
} = configMap
if (type === 'aws-s3' || type === 'aws-s3-plist') {
imgUrl = rawUrl || imgUrl || ''
}
const url = new URL(!/^https?:\/\//.test(imgUrl) ? `http://${imgUrl}` : imgUrl)
let fileKey = url.pathname.replace(/^\/+/, '')
if (pathStyleAccess) {
fileKey = fileKey.replace(/^[^/]+\//, '')
}
const endpointUrl: string | undefined = endpoint
? /^https?:\/\//.test(endpoint)
? endpoint
: `http://${endpoint}`
: undefined
if (endpointUrl && endpointUrl.includes('cloudflarestorage')) {
region = region || 'auto'
}
const sslEnabled = endpointUrl ? endpointUrl.startsWith('https') : true
const agent = getAgent(proxy, sslEnabled)
const commonOptions: AgentOptions = {
keepAlive: true,
keepAliveMsecs: 1000,
scheduling: 'lifo' as 'lifo' | 'fifo' | undefined
}
const extraOptions = sslEnabled ? { rejectUnauthorized: !!rejectUnauthorized } : {}
const handler = sslEnabled
? new NodeHttpHandler({
httpsAgent: agent.https
? agent.https
: new https.Agent({
...commonOptions,
...extraOptions
})
})
: new NodeHttpHandler({
httpAgent: agent.http
? agent.http
: new http.Agent({
...commonOptions,
...extraOptions
})
})
const s3Options: S3ClientConfig = {
credentials: {
accessKeyId: accessKeyID,
secretAccessKey
},
endpoint: endpointUrl,
tls: sslEnabled,
forcePathStyle: pathStyleAccess,
region,
requestHandler: handler
}
if (dogeMode) {
s3Options.credentials = {
accessKeyId: configMap.config.accessKeyID,
secretAccessKey: configMap.config.secretAccessKey,
sessionToken: configMap.config.sessionToken
}
}
let result: any
try {
fileKey = decodeURIComponent(fileKey)
} catch (err: any) {}
try {
const client = new S3Client(s3Options)
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey
})
result = await client.send(command)
} catch (err: any) {
s3Options.region = 'us-east-1'
const client = new S3Client(s3Options)
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey
})
result = await client.send(command)
}
return result.$metadata.httpStatusCode === 204
} catch (err: any) {
console.log(err)
return false
}
}
export async function removeFileFromDogeInMain (configMap: IStringKeyMap) {
try {
const {
config: { bucketName, AccessKey, SecretKey }
} = configMap
const token = (await getDogeToken(AccessKey, SecretKey)) as DogecloudTokenFull
const bucket = token.Buckets?.find(item => item.name === bucketName || item.s3Bucket === bucketName)
const newConfigMap = { ...configMap }
newConfigMap.config = {
...newConfigMap.config,
accessKeyID: token.Credentials?.accessKeyId,
secretAccessKey: token.Credentials?.secretAccessKey,
sessionToken: token.Credentials?.sessionToken,
endpoint: bucket?.s3Endpoint,
region: dogeRegionMap[bucket?.s3Endpoint?.split('.')[1] || 'ap-shanghai'],
bucketName: bucket?.s3Bucket
}
return await removeFileFromS3InMain(newConfigMap, true)
} catch (err: any) {
console.log(err)
return false
}
}
function createHuaweiAuthorization (
bucketName: string,
path: string,
fileName: string,
accessKey: string,
secretKey: string,
date: string = new Date().toUTCString()
) {
const strToSign = `DELETE\n\n\n${date}\n/${bucketName}${path}/${fileName}`
const singature = crypto.createHmac('sha1', secretKey).update(strToSign).digest('base64')
return `OBS ${accessKey}:${singature}`
}
export async function removeFileFromHuaweiInMain (configMap: IStringKeyMap) {
const { fileName, config } = configMap
const { accessKeyId, accessKeySecret, bucketName, endpoint } = config
let path = config.path || '/'
path = `/${path.replace(/^\/+|\/+$/, '')}`
path = path === '/' ? '' : path
const date = new Date().toUTCString()
const authorization = createHuaweiAuthorization(bucketName, path, fileName, accessKeyId, accessKeySecret, date)
try {
const res = await axios.request({
url: `https://${bucketName}.${endpoint}${encodeURI(path)}/${encodeURIComponent(fileName)}`,
method: 'DELETE',
responseType: 'json',
headers: {
Host: `${bucketName}.${endpoint}`,
Date: date,
Authorization: authorization
}
})
return res.status === 204
} catch (error) {
console.log(error)
return false
}
}
export async function removeFileFromSFTPInMain (config: ISftpPlistConfig, fileName: string) {
try {
const client = SSHClient.instance
await client.connect(config)
const uploadPath = `/${config.uploadPath || ''}/`.replace(/\/+/g, '/')
const remote = path.join(uploadPath, fileName)
const deleteResult = await client.deleteFileSFTP(config, remote)
client.close()
return deleteResult
} catch (err: any) {
console.log(err)
return false
}
}
import crypto from 'node:crypto'
import http, { AgentOptions } from 'node:http'
import https from 'node:https'
import path from 'node:path'
import querystring from 'node:querystring'
import type { S3ClientConfig } from '@aws-sdk/client-s3'
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { NodeHttpHandler } from '@smithy/node-http-handler'
import axios from 'axios'
import type { ISftpPlistConfig } from 'piclist'
import type { IObj, IStringKeyMap } from '#/types/types'
import { getAgent } from '~/manage/utils/common'
import SSHClient from '~/utils/sshClient'
interface DogecloudTokenFull {
Credentials: {
accessKeyId: string
secretAccessKey: string
sessionToken: string
}
ExpiredAt: number
Buckets: {
name: string
s3Bucket: string
s3Endpoint: string
}[]
}
const dogeRegionMap: IStringKeyMap = {
'ap-shanghai': '0',
'ap-beijing': '1',
'ap-guangzhou': '2',
'ap-chengdu': '3'
}
async function dogecloudApi (
apiPath: string,
data = {},
jsonMode: boolean = false,
accessKey: string,
secretKey: string
) {
const body = jsonMode ? JSON.stringify(data) : querystring.encode(data)
const sign = crypto
.createHmac('sha1', secretKey)
.update(Buffer.from(apiPath + '\n' + body, 'utf8'))
.digest('hex')
const authorization = `TOKEN ${accessKey}:${sign}`
try {
const res = await axios.request({
url: `https://api.dogecloud.com${apiPath}`,
method: 'POST',
data: body,
responseType: 'json',
headers: {
'Content-Type': jsonMode ? 'application/json' : 'application/x-www-form-urlencoded',
Authorization: authorization
}
})
if (res.data.code !== 200) {
throw new Error('API Error')
}
return res.data.data
} catch (err: any) {
throw new Error('API Error')
}
}
async function getDogeToken (accessKey: string, secretKey: string): Promise<IObj | DogecloudTokenFull> {
try {
const data = await dogecloudApi(
'/auth/tmp_token.json',
{
channel: 'OSS_FULL',
scopes: ['*']
},
true,
accessKey,
secretKey
)
return data
} catch (err: any) {
console.log(err)
return {}
}
}
export async function removeFileFromS3InMain (configMap: IStringKeyMap, dogeMode: boolean = false) {
try {
const {
url: rawUrl,
type,
config: { accessKeyID, secretAccessKey, bucketName, endpoint, pathStyleAccess, rejectUnauthorized, proxy }
} = configMap
let {
imgUrl,
config: { region }
} = configMap
if (type === 'aws-s3' || type === 'aws-s3-plist') {
imgUrl = rawUrl || imgUrl || ''
}
const url = new URL(!/^https?:\/\//.test(imgUrl) ? `http://${imgUrl}` : imgUrl)
let fileKey = url.pathname.replace(/^\/+/, '')
if (pathStyleAccess) {
fileKey = fileKey.replace(/^[^/]+\//, '')
}
const endpointUrl: string | undefined = endpoint
? /^https?:\/\//.test(endpoint)
? endpoint
: `http://${endpoint}`
: undefined
if (endpointUrl && endpointUrl.includes('cloudflarestorage')) {
region = region || 'auto'
}
const sslEnabled = endpointUrl ? endpointUrl.startsWith('https') : true
const agent = getAgent(proxy, sslEnabled)
const commonOptions: AgentOptions = {
keepAlive: true,
keepAliveMsecs: 1000,
scheduling: 'lifo' as 'lifo' | 'fifo' | undefined
}
const extraOptions = sslEnabled ? { rejectUnauthorized: !!rejectUnauthorized } : {}
const handler = sslEnabled
? new NodeHttpHandler({
httpsAgent: agent.https
? agent.https
: new https.Agent({
...commonOptions,
...extraOptions
})
})
: new NodeHttpHandler({
httpAgent: agent.http
? agent.http
: new http.Agent({
...commonOptions,
...extraOptions
})
})
const s3Options: S3ClientConfig = {
credentials: {
accessKeyId: accessKeyID,
secretAccessKey
},
endpoint: endpointUrl,
tls: sslEnabled,
forcePathStyle: pathStyleAccess,
region,
requestHandler: handler
}
if (dogeMode) {
s3Options.credentials = {
accessKeyId: configMap.config.accessKeyID,
secretAccessKey: configMap.config.secretAccessKey,
sessionToken: configMap.config.sessionToken
}
}
let result: any
try {
fileKey = decodeURIComponent(fileKey)
} catch (err: any) {}
try {
const client = new S3Client(s3Options)
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey
})
result = await client.send(command)
} catch (err: any) {
s3Options.region = 'us-east-1'
const client = new S3Client(s3Options)
const command = new DeleteObjectCommand({
Bucket: bucketName,
Key: fileKey
})
result = await client.send(command)
}
return result.$metadata.httpStatusCode === 204
} catch (err: any) {
console.log(err)
return false
}
}
export async function removeFileFromDogeInMain (configMap: IStringKeyMap) {
try {
const {
config: { bucketName, AccessKey, SecretKey }
} = configMap
const token = (await getDogeToken(AccessKey, SecretKey)) as DogecloudTokenFull
const bucket = token.Buckets?.find(item => item.name === bucketName || item.s3Bucket === bucketName)
const newConfigMap = { ...configMap }
newConfigMap.config = {
...newConfigMap.config,
accessKeyID: token.Credentials?.accessKeyId,
secretAccessKey: token.Credentials?.secretAccessKey,
sessionToken: token.Credentials?.sessionToken,
endpoint: bucket?.s3Endpoint,
region: dogeRegionMap[bucket?.s3Endpoint?.split('.')[1] || 'ap-shanghai'],
bucketName: bucket?.s3Bucket
}
return await removeFileFromS3InMain(newConfigMap, true)
} catch (err: any) {
console.log(err)
return false
}
}
function createHuaweiAuthorization (
bucketName: string,
path: string,
fileName: string,
accessKey: string,
secretKey: string,
date: string = new Date().toUTCString()
) {
const strToSign = `DELETE\n\n\n${date}\n/${bucketName}${path}/${fileName}`
const singature = crypto.createHmac('sha1', secretKey).update(strToSign).digest('base64')
return `OBS ${accessKey}:${singature}`
}
export async function removeFileFromHuaweiInMain (configMap: IStringKeyMap) {
const { fileName, config } = configMap
const { accessKeyId, accessKeySecret, bucketName, endpoint } = config
let path = config.path || '/'
path = `/${path.replace(/^\/+|\/+$/, '')}`
path = path === '/' ? '' : path
const date = new Date().toUTCString()
const authorization = createHuaweiAuthorization(bucketName, path, fileName, accessKeyId, accessKeySecret, date)
try {
const res = await axios.request({
url: `https://${bucketName}.${endpoint}${encodeURI(path)}/${encodeURIComponent(fileName)}`,
method: 'DELETE',
responseType: 'json',
headers: {
Host: `${bucketName}.${endpoint}`,
Date: date,
Authorization: authorization
}
})
return res.status === 204
} catch (error) {
console.log(error)
return false
}
}
export async function removeFileFromSFTPInMain (config: ISftpPlistConfig, fileName: string) {
try {
const client = SSHClient.instance
await client.connect(config)
const uploadPath = `/${config.uploadPath || ''}/`.replace(/\/+/g, '/')
const remote = path.join(uploadPath, fileName)
const deleteResult = await client.deleteFileSFTP(config, remote)
client.close()
return deleteResult
} catch (err: any) {
console.log(err)
return false
}
}

View File

@@ -1,65 +1,66 @@
<template>
<div
id="app"
:key="pageReloadCount"
>
<router-view />
<UIServiceProvider />
</div>
</template>
<script lang="ts" setup>
import type { IConfig } from 'piclist'
import { onBeforeMount, onMounted } from 'vue'
import UIServiceProvider from '@/components/ui/UIServiceProvider.vue'
import { useATagClick } from '@/hooks/useATagClick'
import { useStore } from '@/hooks/useStore'
import { getConfig } from '@/utils/dataSender'
import { pageReloadCount } from '@/utils/global'
import { useAppStore } from './hooks/useAppStore'
useATagClick()
const store = useStore()
const appStore = useAppStore()
onBeforeMount(async () => {
const config = await getConfig<IConfig>()
if (config) {
store?.setDefaultPicBed(config?.picBed?.uploader || config?.picBed?.current || 'smms')
}
})
onMounted(async () => {
try {
appStore.init()
} catch (error) {
console.error('Failed to load settings:', error)
}
})
</script>
<script lang="ts">
export default {
name: 'PicGoApp'
}
</script>
<style lang="stylus">
body,
html
padding 0
margin 0
height 100%
font-family "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif
#app
height 100%
user-select none
overflow hidden
.el-button-group
width 100%
.el-button
width 50%
</style>
<template>
<div
id="app"
:key="pageReloadCount"
>
<router-view />
<UIServiceProvider />
</div>
</template>
<script lang="ts" setup>
import type { IConfig } from 'piclist'
import { onBeforeMount, onMounted } from 'vue'
import UIServiceProvider from '@/components/ui/UIServiceProvider.vue'
import { useATagClick } from '@/hooks/useATagClick'
import { useStore } from '@/hooks/useStore'
import { getConfig } from '@/utils/dataSender'
import { pageReloadCount } from '@/utils/global'
import { useAppStore } from './hooks/useAppStore'
useATagClick()
const store = useStore()
const appStore = useAppStore()
onBeforeMount(async () => {
const config = await getConfig<IConfig>()
if (config) {
store?.setDefaultPicBed(config?.picBed?.uploader || config?.picBed?.current || 'smms')
}
})
onMounted(async () => {
try {
appStore.init()
} catch (error) {
console.error('Failed to load settings:', error)
}
})
</script>
<script lang="ts">
export default {
name: 'PicGoApp'
}
</script>
<style lang="stylus">
body,
html
padding 0
margin 0
height 100%
font-family "Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif
#app
height 100%
user-select none
overflow hidden
.el-button-group
width 100%
.el-button
width 50%
</style>

View File

@@ -1,23 +1,24 @@
import { onMounted, onUnmounted } from 'vue'
import { IRPCActionType } from '@/utils/enum'
export function useATagClick () {
const handleATagClick = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
if (e.target.href) {
// avoid opening localhost development URLs in external browser
if (!e.target.href.startsWith('http://localhost:3000')) {
e.preventDefault()
window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href)
}
}
}
}
onMounted(() => {
document.addEventListener('click', handleATagClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleATagClick)
})
}
import { onMounted, onUnmounted } from 'vue'
import { IRPCActionType } from '@/utils/enum'
export function useATagClick () {
const handleATagClick = (e: MouseEvent) => {
if (e.target instanceof HTMLAnchorElement) {
if (e.target.href) {
if (!e.target.href.startsWith('http://localhost:3000')) {
e.preventDefault()
window.electron.sendRPC(IRPCActionType.OPEN_URL, e.target.href)
}
}
}
}
onMounted(() => {
document.addEventListener('click', handleATagClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleATagClick)
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,55 @@
import 'video.js/dist/video-js.css'
import 'highlight.js/styles/stackoverflow-light.css'
import 'highlight.js/lib/common'
import hljsVuePlugin from '@highlightjs/vue-plugin'
import VueVideoPlayer from '@videojs-player/vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import VueLazyLoad from 'vue3-lazyload'
import App from '@/App.vue'
import en from '@/i18n/locales/en.json'
import zhCN from '@/i18n/locales/zh-CN.json'
import zhTW from '@/i18n/locales/zh-TW.json'
import router from '@/router'
import { store } from '@/store'
import db from '@/utils/db'
type MessageSchema = typeof zhCN
window.electron.setVisualZoomLevelLimits(1, 1)
const app = createApp(App)
app.config.globalProperties.$$db = db
app.config.globalProperties.triggerRPC = window.electron.triggerRPC
app.config.globalProperties.sendRPC = window.electron.sendRPC
app.config.globalProperties.sendToMain = window.electron.sendToMain
const i18n = createI18n<[MessageSchema], 'en' | 'zh-CN' | 'zh-TW'>({
legacy: false,
locale: localStorage.getItem('currentLanguage') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
en,
'zh-CN': zhCN,
'zh-TW': zhTW
}
})
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(VueLazyLoad, {
loading: '/loading.jpg',
error: '/unknown-file-type.svg',
delay: 500
})
app.use(i18n)
app.use(router)
app.use(store)
app.use(pinia)
app.use(hljsVuePlugin)
app.use(VueVideoPlayer)
app.mount('#app')
export {
i18n
}
import 'video.js/dist/video-js.css'
import 'highlight.js/styles/stackoverflow-light.css'
import 'highlight.js/lib/common'
import hljsVuePlugin from '@highlightjs/vue-plugin'
import VueVideoPlayer from '@videojs-player/vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import VueLazyLoad from 'vue3-lazyload'
import App from '@/App.vue'
import en from '@/i18n/locales/en.json'
import zhCN from '@/i18n/locales/zh-CN.json'
import zhTW from '@/i18n/locales/zh-TW.json'
import router from '@/router'
import { store } from '@/store'
import db from '@/utils/db'
type MessageSchema = typeof zhCN
window.electron.setVisualZoomLevelLimits(1, 1)
const app = createApp(App)
app.config.globalProperties.$$db = db
app.config.globalProperties.triggerRPC = window.electron.triggerRPC
app.config.globalProperties.sendRPC = window.electron.sendRPC
app.config.globalProperties.sendToMain = window.electron.sendToMain
const i18n = createI18n<[MessageSchema], 'en' | 'zh-CN' | 'zh-TW'>({
legacy: false,
locale: localStorage.getItem('currentLanguage') || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
en,
'zh-CN': zhCN,
'zh-TW': zhTW
}
})
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(VueLazyLoad, {
loading: '/loading.jpg',
error: '/unknown-file-type.svg',
delay: 500
})
app.use(i18n)
app.use(router)
app.use(store)
app.use(pinia)
app.use(hljsVuePlugin)
app.use(VueVideoPlayer)
app.mount('#app')

View File

@@ -1,444 +1,445 @@
<template>
<div class="upload-container">
<!-- Header Card -->
<div class="upload-card header-card">
<div class="card-header">
<div class="provider-section">
<button
class="provider-button"
:title="t('pages.upload.uploadViewHint')"
@click="handlePicBedNameClick(picBedName, picBedConfigName)"
>
<div class="provider-info">
<span class="provider-name">{{ picBedName }}</span>
<span class="provider-config">{{ picBedConfigName || 'Default' }}</span>
</div>
<ChevronDownIcon
:size="16"
class="provider-arrow"
/>
</button>
</div>
<div class="header-actions">
<button
class="action-button secondary"
@click="handleImageProcess"
>
<Settings :size="16" />
<span>{{ t('pages.upload.imageProcessName') }}</span>
</button>
<button
class="action-button"
@click="handleChangePicBed"
>
<DatabaseIcon :size="16" />
<span>{{ t('pages.upload.changePicBed') }}</span>
</button>
</div>
</div>
</div>
<!-- Main Upload Card -->
<div class="upload-card main-card">
<div
class="upload-zone"
:class="{ 'drag-active': dragover }"
@drop.prevent="onDrop"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
@click="openUplodWindow"
>
<div class="upload-content">
<div class="upload-icon">
<UploadCloudIcon :size="48" />
</div>
<div class="upload-text">
<h3 class="upload-title">
{{ t('pages.upload.dragFileToHere') }}
</h3>
<p class="upload-subtitle">
{{ t('pages.upload.clickToUpload') }}
</p>
<div class="upload-formats">
<span class="format-label">{{ t('pages.upload.uploadHint') }}</span>
</div>
</div>
</div>
<input
id="file-uploader"
ref="fileInput"
type="file"
multiple
style="display: none"
@change="onChange"
>
</div>
<!-- Progress Bar -->
<transition name="progress">
<div
v-if="showProgress"
class="progress-container"
>
<div class="progress-bar">
<div
class="progress-fill"
:class="{ 'progress-error': showError }"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="progress-text">
{{ showError ? t('pages.upload.uploadFailed') : `${progress}%` }}
</span>
</div>
</transition>
</div>
<!-- Quick Actions Card -->
<div class="upload-card actions-card">
<div class="card-header">
<h4 class="card-title">
{{ t('pages.upload.quickUpload') }}
</h4>
</div>
<div class="quick-actions">
<button
class="quick-action-button"
@click="uploadClipboardFiles"
>
<ClipboardIcon :size="20" />
<span>{{ t('pages.upload.clipboardPicture') }}</span>
</button>
<button
class="quick-action-button"
@click="uploadURLFiles"
>
<LinkIcon :size="20" />
<span>{{ t('pages.upload.urlUpload') }}</span>
</button>
</div>
</div>
<!-- Settings Card -->
<div class="upload-card settings-card">
<div class="card-header">
<h4 class="card-title">
{{ t('pages.upload.linkFormat') }}
</h4>
</div>
<div class="settings-content">
<!-- Format Options -->
<div class="setting-group">
<label class="setting-label">{{ t('pages.upload.outputFormat') }}</label>
<div class="format-buttons">
<button
v-for="(format, key) in pasteFormatList"
:key="key"
class="format-button"
:class="{ active: pasteStyle === key }"
:title="format"
@click="updatePasteStyle(key)"
>
{{ key }}
</button>
</div>
</div>
<!-- URL Length Options -->
<div class="setting-group">
<label class="setting-label">{{ t('pages.upload.urlType.title') }}</label>
<div class="url-toggle">
<button
class="toggle-button"
:class="{ active: !useShortUrl }"
@click="updateUrlType(false)"
>
<span>{{ t('pages.upload.urlType.normal') }}</span>
</button>
<button
class="toggle-button"
:class="{ active: useShortUrl }"
@click="updateUrlType(true)"
>
<span>{{ t('pages.upload.urlType.short') }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- Image Process Dialog -->
<transition name="modal">
<div
v-if="imageProcessDialogVisible"
class="modal-overlay"
@click="imageProcessDialogVisible = false"
>
<div
class="modal-container"
@click.stop
>
<div class="modal-header">
<h3 class="modal-title">
{{ t('pages.imageProcess.title') }}
</h3>
<button
class="modal-close"
@click="imageProcessDialogVisible = false"
>
<XIcon :size="20" />
</button>
</div>
<div class="modal-content">
<ImageProcessSetting v-model="imageProcessDialogVisible" />
</div>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import { ChevronDownIcon, ClipboardIcon, DatabaseIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import ImageProcessSetting from '@/components/ImageProcessSetting.vue'
import useMessage from '@/hooks/useMessage'
import { PICBEDS_PAGE } from '@/router/config'
import $bus from '@/utils/bus'
import { isUrl } from '@/utils/common'
import { configPaths } from '@/utils/configPaths'
import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { useDragEventListeners } from '@/utils/drag'
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
import { picBedGlobal, updatePicBedGlobal } from '@/utils/global'
import type { IFileWithPath, IUploaderConfigItem } from '#/types/types'
useDragEventListeners()
const $router = useRouter()
const { t } = useI18n()
const message = useMessage()
const imageProcessDialogVisible = ref(false)
const useShortUrl = ref(false)
const dragover = ref(false)
const progress = ref(0)
const showProgress = ref(false)
const showError = ref(false)
const pasteStyle = ref('')
const picBedName = ref('')
const picBedConfigName = ref('')
const fileInput = ref<HTMLInputElement>()
const pasteFormatList = ref<Record<string, string>>({
[IPasteStyle.MARKDOWN]: '![alt](url)',
[IPasteStyle.HTML]: '<img src="url"/>',
[IPasteStyle.URL]: 'http://test.com/test.png',
[IPasteStyle.UBB]: '[img]url[/img]',
[IPasteStyle.CUSTOM]: ''
})
watch(picBedGlobal, () => {
getDefaultPicBed()
})
const uploadProgressHandler = (_event: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
showProgress.value = true
progress.value = _progress
} else {
progress.value = 100
showError.value = true
}
}
const syncPicBedHandler = () => {
getDefaultPicBed()
}
onBeforeMount(() => {
updatePicBedGlobal()
window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
getUseShortUrl()
getPasteStyle()
getDefaultPicBed()
window.electron.ipcRendererOn('syncPicBed', syncPicBedHandler)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
})
const handleImageProcess = () => {
imageProcessDialogVisible.value = true
}
watch(progress, onProgressChange)
function onProgressChange (val: number) {
if (val === 100) {
setTimeout(() => {
showProgress.value = false
showError.value = false
}, 1000)
setTimeout(() => {
progress.value = 0
}, 1200)
}
}
async function handlePicBedNameClick (_picBedName: string, picBedConfigName: string | undefined) {
const formatedpicBedConfigName = picBedConfigName || 'Default'
const currentPicBed = await getConfig<string>(configPaths.picBed.current)
const currentPicBedConfig = ((await getConfig<any[]>(`uploader.${currentPicBed}`)) as any) || {}
const configList = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, currentPicBed)
const currentConfigList = configList?.configList ?? []
const config = currentConfigList.find((item: any) => item._configName === formatedpicBedConfigName)
$router.push({
name: PICBEDS_PAGE,
params: {
type: currentPicBed,
configId: config?._id || ''
},
query: {
defaultConfigId: currentPicBedConfig.defaultId || ''
}
})
}
onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
window.electron.ipcRendererRemoveListener('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererRemoveListener('syncPicBed', syncPicBedHandler)
})
function onDrop (e: DragEvent) {
dragover.value = false
// send files first
if (e.dataTransfer?.files?.length) {
ipcSendFiles(e.dataTransfer.files)
} else if (e.dataTransfer?.items) {
const items = e.dataTransfer.items
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer.getData(items[0].type)
if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: urlMatch[1]
}
])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
function openUplodWindow () {
fileInput.value?.click()
}
function onChange (e: any) {
ipcSendFiles(e.target.files)
;(fileInput.value as HTMLInputElement).value = ''
}
function ipcSendFiles (files: FileList) {
const sendFiles: IFileWithPath[] = []
Array.from(files).forEach(item => {
const obj = {
name: item.name,
path: window.electron.showFilePath(item)
}
sendFiles.push(obj)
})
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, sendFiles)
}
async function getPasteStyle () {
pasteStyle.value = (await getConfig(configPaths.settings.pasteStyle)) || IPasteStyle.MARKDOWN
pasteFormatList.value.Custom = (await getConfig(configPaths.settings.customLink)) || '![$fileName]($url)'
}
async function getUseShortUrl () {
useShortUrl.value = (await getConfig(configPaths.settings.useShortUrl)) || false
}
function updatePasteStyle (style: string) {
pasteStyle.value = style
saveConfig({
[configPaths.settings.pasteStyle]: style || IPasteStyle.MARKDOWN
})
}
function updateUrlType (shortUrl: boolean) {
useShortUrl.value = shortUrl
saveConfig({
[configPaths.settings.useShortUrl]: shortUrl
})
}
function uploadClipboardFiles () {
window.electron.sendRPC(IRPCActionType.UPLOAD_CLIPBOARD_FILES_FROM_UPLOAD_PAGE)
}
async function uploadURLFiles () {
const str = await navigator.clipboard.readText()
$bus.emit(SHOW_INPUT_BOX, {
value: isUrl(str) ? str : '',
title: t('pages.upload.inputUrlTip'),
placeholder: t('pages.upload.httpPrefixTip')
})
}
function handleInputBoxValue (val: string) {
if (val === '') return
if (isUrl(val)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: val
}
])
} else {
message.error(t('pages.upload.inputValidUrl'))
}
}
async function getDefaultPicBed () {
const currentPicBed = await getConfig<string>(configPaths.picBed.current)
picBedGlobal.value.forEach(item => {
if (item.type === currentPicBed) {
picBedName.value = item.name
}
})
picBedConfigName.value = (await getConfig<string>(`picBed.${currentPicBed}._configName`)) || ''
}
async function handleChangePicBed () {
window.electron.sendRPC(IRPCActionType.SHOW_UPLOAD_PAGE_MENU)
}
</script>
<script lang="ts">
export default {
name: 'UploadPage'
}
</script>
<style scoped src="./css/UploadPage.css"></style>
<template>
<div class="upload-container">
<!-- Header Card -->
<div class="upload-card header-card">
<div class="card-header">
<div class="provider-section">
<button
class="provider-button"
:title="t('pages.upload.uploadViewHint')"
@click="handlePicBedNameClick(picBedName, picBedConfigName)"
>
<div class="provider-info">
<span class="provider-name">{{ picBedName }}</span>
<span class="provider-config">{{ picBedConfigName || 'Default' }}</span>
</div>
<ChevronDownIcon
:size="16"
class="provider-arrow"
/>
</button>
</div>
<div class="header-actions">
<button
class="action-button secondary"
@click="handleImageProcess"
>
<Settings :size="16" />
<span>{{ t('pages.upload.imageProcessName') }}</span>
</button>
<button
class="action-button"
@click="handleChangePicBed"
>
<DatabaseIcon :size="16" />
<span>{{ t('pages.upload.changePicBed') }}</span>
</button>
</div>
</div>
</div>
<!-- Main Upload Card -->
<div class="upload-card main-card">
<div
id="upload-area"
class="upload-zone"
:class="{ 'drag-active': dragover }"
@drop.prevent="onDrop"
@dragover.prevent="dragover = true"
@dragleave.prevent="dragover = false"
@click="openUplodWindow"
>
<div class="upload-content">
<div class="upload-icon">
<UploadCloudIcon :size="48" />
</div>
<div class="upload-text">
<h3 class="upload-title">
{{ t('pages.upload.dragFileToHere') }}
</h3>
<p class="upload-subtitle">
{{ t('pages.upload.clickToUpload') }}
</p>
<div class="upload-formats">
<span class="format-label">{{ t('pages.upload.uploadHint') }}</span>
</div>
</div>
</div>
<input
id="file-uploader"
ref="fileInput"
type="file"
multiple
style="display: none"
@change="onChange"
>
</div>
<!-- Progress Bar -->
<transition name="progress">
<div
v-if="showProgress"
class="progress-container"
>
<div class="progress-bar">
<div
class="progress-fill"
:class="{ 'progress-error': showError }"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="progress-text">
{{ showError ? t('pages.upload.uploadFailed') : `${progress}%` }}
</span>
</div>
</transition>
</div>
<!-- Quick Actions Card -->
<div class="upload-card actions-card">
<div class="card-header">
<h4 class="card-title">
{{ t('pages.upload.quickUpload') }}
</h4>
</div>
<div class="quick-actions">
<button
class="quick-action-button"
@click="uploadClipboardFiles"
>
<ClipboardIcon :size="20" />
<span>{{ t('pages.upload.clipboardPicture') }}</span>
</button>
<button
class="quick-action-button"
@click="uploadURLFiles"
>
<LinkIcon :size="20" />
<span>{{ t('pages.upload.urlUpload') }}</span>
</button>
</div>
</div>
<!-- Settings Card -->
<div class="upload-card settings-card">
<div class="card-header">
<h4 class="card-title">
{{ t('pages.upload.linkFormat') }}
</h4>
</div>
<div class="settings-content">
<!-- Format Options -->
<div class="setting-group">
<label class="setting-label">{{ t('pages.upload.outputFormat') }}</label>
<div class="format-buttons">
<button
v-for="(format, key) in pasteFormatList"
:key="key"
class="format-button"
:class="{ active: pasteStyle === key }"
:title="format"
@click="updatePasteStyle(key)"
>
{{ key }}
</button>
</div>
</div>
<!-- URL Length Options -->
<div class="setting-group">
<label class="setting-label">{{ t('pages.upload.urlType.title') }}</label>
<div class="url-toggle">
<button
class="toggle-button"
:class="{ active: !useShortUrl }"
@click="updateUrlType(false)"
>
<span>{{ t('pages.upload.urlType.normal') }}</span>
</button>
<button
class="toggle-button"
:class="{ active: useShortUrl }"
@click="updateUrlType(true)"
>
<span>{{ t('pages.upload.urlType.short') }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- Image Process Dialog -->
<transition name="modal">
<div
v-if="imageProcessDialogVisible"
class="modal-overlay"
@click="imageProcessDialogVisible = false"
>
<div
class="modal-container"
@click.stop
>
<div class="modal-header">
<h3 class="modal-title">
{{ t('pages.imageProcess.title') }}
</h3>
<button
class="modal-close"
@click="imageProcessDialogVisible = false"
>
<XIcon :size="20" />
</button>
</div>
<div class="modal-content">
<ImageProcessSetting v-model="imageProcessDialogVisible" />
</div>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import type { IpcRendererEvent } from 'electron'
import { ChevronDownIcon, ClipboardIcon, DatabaseIcon, LinkIcon, Settings, UploadCloudIcon, XIcon } from 'lucide-vue-next'
import { onBeforeMount, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import ImageProcessSetting from '@/components/ImageProcessSetting.vue'
import useMessage from '@/hooks/useMessage'
import { PICBEDS_PAGE } from '@/router/config'
import $bus from '@/utils/bus'
import { isUrl } from '@/utils/common'
import { configPaths } from '@/utils/configPaths'
import { SHOW_INPUT_BOX, SHOW_INPUT_BOX_RESPONSE } from '@/utils/constant'
import { getConfig, saveConfig } from '@/utils/dataSender'
import { useDragEventListeners } from '@/utils/drag'
import { IPasteStyle, IRPCActionType } from '@/utils/enum'
import { picBedGlobal, updatePicBedGlobal } from '@/utils/global'
import type { IFileWithPath, IUploaderConfigItem } from '#/types/types'
useDragEventListeners()
const $router = useRouter()
const { t } = useI18n()
const message = useMessage()
const imageProcessDialogVisible = ref(false)
const useShortUrl = ref(false)
const dragover = ref(false)
const progress = ref(0)
const showProgress = ref(false)
const showError = ref(false)
const pasteStyle = ref('')
const picBedName = ref('')
const picBedConfigName = ref('')
const fileInput = ref<HTMLInputElement>()
const pasteFormatList = ref<Record<string, string>>({
[IPasteStyle.MARKDOWN]: '![alt](url)',
[IPasteStyle.HTML]: '<img src="url"/>',
[IPasteStyle.URL]: 'http://test.com/test.png',
[IPasteStyle.UBB]: '[img]url[/img]',
[IPasteStyle.CUSTOM]: ''
})
watch(picBedGlobal, () => {
getDefaultPicBed()
})
const uploadProgressHandler = (_event: IpcRendererEvent, _progress: number) => {
if (_progress !== -1) {
showProgress.value = true
progress.value = _progress
} else {
progress.value = 100
showError.value = true
}
}
const syncPicBedHandler = () => {
getDefaultPicBed()
}
onBeforeMount(() => {
updatePicBedGlobal()
window.electron.ipcRendererOn('uploadProgress', uploadProgressHandler)
getUseShortUrl()
getPasteStyle()
getDefaultPicBed()
window.electron.ipcRendererOn('syncPicBed', syncPicBedHandler)
$bus.on(SHOW_INPUT_BOX_RESPONSE, handleInputBoxValue)
})
const handleImageProcess = () => {
imageProcessDialogVisible.value = true
}
watch(progress, onProgressChange)
function onProgressChange (val: number) {
if (val === 100) {
setTimeout(() => {
showProgress.value = false
showError.value = false
}, 1000)
setTimeout(() => {
progress.value = 0
}, 1200)
}
}
async function handlePicBedNameClick (_picBedName: string, picBedConfigName: string | undefined) {
const formatedpicBedConfigName = picBedConfigName || 'Default'
const currentPicBed = await getConfig<string>(configPaths.picBed.current)
const currentPicBedConfig = ((await getConfig<any[]>(`uploader.${currentPicBed}`)) as any) || {}
const configList = await window.electron.triggerRPC<IUploaderConfigItem>(IRPCActionType.PICBED_GET_CONFIG_LIST, currentPicBed)
const currentConfigList = configList?.configList ?? []
const config = currentConfigList.find((item: any) => item._configName === formatedpicBedConfigName)
$router.push({
name: PICBEDS_PAGE,
params: {
type: currentPicBed,
configId: config?._id || ''
},
query: {
defaultConfigId: currentPicBedConfig.defaultId || ''
}
})
}
onBeforeUnmount(() => {
$bus.off(SHOW_INPUT_BOX_RESPONSE)
window.electron.ipcRendererRemoveListener('uploadProgress', uploadProgressHandler)
window.electron.ipcRendererRemoveListener('syncPicBed', syncPicBedHandler)
})
function onDrop (e: DragEvent) {
dragover.value = false
// send files first
if (e.dataTransfer?.files?.length) {
ipcSendFiles(e.dataTransfer.files)
} else if (e.dataTransfer?.items) {
const items = e.dataTransfer.items
if (items.length === 2 && items[0].type === 'text/uri-list') {
handleURLDrag(items, e.dataTransfer)
} else if (items[0].type === 'text/plain') {
const str = e.dataTransfer.getData(items[0].type)
if (isUrl(str)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [{ path: str }])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
}
}
function handleURLDrag (items: DataTransferItemList, dataTransfer: DataTransfer) {
// text/html
// Use this data to get a more precise URL
const urlString = dataTransfer.getData(items[1].type)
const urlMatch = urlString.match(/<img.*src="(.*?)"/)
if (urlMatch) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: urlMatch[1]
}
])
} else {
message.error(t('pages.upload.dragValidPictureOrUrl'))
}
}
function openUplodWindow () {
fileInput.value?.click()
}
function onChange (e: any) {
ipcSendFiles(e.target.files)
;(fileInput.value as HTMLInputElement).value = ''
}
function ipcSendFiles (files: FileList) {
const sendFiles: IFileWithPath[] = []
Array.from(files).forEach(item => {
const obj = {
name: item.name,
path: window.electron.showFilePath(item)
}
sendFiles.push(obj)
})
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, sendFiles)
}
async function getPasteStyle () {
pasteStyle.value = (await getConfig(configPaths.settings.pasteStyle)) || IPasteStyle.MARKDOWN
pasteFormatList.value.Custom = (await getConfig(configPaths.settings.customLink)) || '![$fileName]($url)'
}
async function getUseShortUrl () {
useShortUrl.value = (await getConfig(configPaths.settings.useShortUrl)) || false
}
function updatePasteStyle (style: string) {
pasteStyle.value = style
saveConfig({
[configPaths.settings.pasteStyle]: style || IPasteStyle.MARKDOWN
})
}
function updateUrlType (shortUrl: boolean) {
useShortUrl.value = shortUrl
saveConfig({
[configPaths.settings.useShortUrl]: shortUrl
})
}
function uploadClipboardFiles () {
window.electron.sendRPC(IRPCActionType.UPLOAD_CLIPBOARD_FILES_FROM_UPLOAD_PAGE)
}
async function uploadURLFiles () {
const str = await navigator.clipboard.readText()
$bus.emit(SHOW_INPUT_BOX, {
value: isUrl(str) ? str : '',
title: t('pages.upload.inputUrlTip'),
placeholder: t('pages.upload.httpPrefixTip')
})
}
function handleInputBoxValue (val: string) {
if (val === '') return
if (isUrl(val)) {
window.electron.sendRPC(IRPCActionType.UPLOAD_CHOOSED_FILES, [
{
path: val
}
])
} else {
message.error(t('pages.upload.inputValidUrl'))
}
}
async function getDefaultPicBed () {
const currentPicBed = await getConfig<string>(configPaths.picBed.current)
picBedGlobal.value.forEach(item => {
if (item.type === currentPicBed) {
picBedName.value = item.name
}
})
picBedConfigName.value = (await getConfig<string>(`picBed.${currentPicBed}._configName`)) || ''
}
async function handleChangePicBed () {
window.electron.sendRPC(IRPCActionType.SHOW_UPLOAD_PAGE_MENU)
}
</script>
<script lang="ts">
export default {
name: 'UploadPage'
}
</script>
<style scoped src="./css/UploadPage.css"></style>