├── .changeset └── config.json ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── report-bug.md ├── dependabot.yml └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── apps └── picsharp-app │ ├── components.json │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── favicon.ico │ └── logo.png │ ├── release-notes.json │ ├── src-tauri │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ ├── Entitlements.plist │ ├── Info.plist │ ├── bin │ │ └── cli.rs │ ├── build.rs │ ├── capabilities │ │ ├── default.json │ │ └── desktop.json │ ├── icons │ │ ├── 128x128.png │ │ ├── 128x128@2x.png │ │ ├── 32x32.png │ │ ├── 32x32_.png │ │ ├── 32x32__.png │ │ ├── _128x128.png │ │ ├── _128x128@2x.png │ │ ├── _icon.ico │ │ ├── _icon.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png │ ├── resources │ │ └── settings.default.json │ ├── src │ │ ├── command.rs │ │ ├── file.rs │ │ ├── file_ext │ │ │ ├── finder.rs │ │ │ └── mod.rs │ │ ├── image_processor │ │ │ ├── common.rs │ │ │ ├── compressors │ │ │ │ ├── avif.rs │ │ │ │ ├── jpeg.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── png.rs │ │ │ │ ├── svg.rs │ │ │ │ └── webp.rs │ │ │ └── mod.rs │ │ ├── inspect.rs │ │ ├── lib.rs │ │ ├── main.rs │ │ ├── stream.rs │ │ ├── tinify.rs │ │ ├── upload.rs │ │ └── window.rs │ └── tauri.conf.json5 │ ├── src │ ├── App.tsx │ ├── assets │ │ ├── logo-96x96.png │ │ └── logo.png │ ├── components │ │ ├── background-pattern.tsx │ │ ├── checkbox-group.tsx │ │ ├── data-table │ │ │ ├── data-table-column-header.tsx │ │ │ ├── data-table-faceted-filter.tsx │ │ │ ├── data-table-pagination.tsx │ │ │ ├── data-table-toolbar.tsx │ │ │ └── index.tsx │ │ ├── dropdown-button.tsx │ │ ├── error-boundary.tsx │ │ ├── fullscreen-progress.tsx │ │ ├── img-tag │ │ │ └── index.tsx │ │ ├── layouts │ │ │ ├── app-layout.tsx │ │ │ └── sidebar-nav.tsx │ │ ├── link.tsx │ │ ├── message │ │ │ ├── README.md │ │ │ └── index.tsx │ │ ├── theme-provider.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ └── tooltip.tsx │ ├── constants.ts │ ├── hooks │ │ ├── useNavigate.ts │ │ └── useSelector.ts │ ├── i18n │ │ ├── index.ts │ │ └── locales │ │ │ ├── en-US.ts │ │ │ └── zh-CN.ts │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── compression │ │ │ ├── classic-file-manager.tsx │ │ │ ├── classic-guide.tsx │ │ │ ├── classic.tsx │ │ │ ├── file-card.tsx │ │ │ ├── index.tsx │ │ │ ├── toolbar-compress.tsx │ │ │ ├── toolbar-exit.tsx │ │ │ ├── toolbar-info.tsx │ │ │ ├── toolbar-pagination.tsx │ │ │ ├── toolbar-select.tsx │ │ │ ├── toolbar.tsx │ │ │ ├── watch-file-manager.tsx │ │ │ ├── watch-guide.tsx │ │ │ └── watch.tsx │ │ ├── image-compare │ │ │ └── index.tsx │ │ ├── message-demo.tsx │ │ ├── settings │ │ │ ├── about │ │ │ │ ├── feedback.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── version.tsx │ │ │ ├── compression │ │ │ │ ├── concurrency.tsx │ │ │ │ ├── convert.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── level.tsx │ │ │ │ ├── mode.tsx │ │ │ │ ├── output.tsx │ │ │ │ ├── threshold.tsx │ │ │ │ └── type.tsx │ │ │ ├── general │ │ │ │ ├── autostart.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── language.tsx │ │ │ │ ├── notification.tsx │ │ │ │ └── update.tsx │ │ │ ├── header.tsx │ │ │ ├── index.tsx │ │ │ ├── section.tsx │ │ │ ├── setting-item.tsx │ │ │ ├── sidebar-nav.tsx │ │ │ └── tinypng │ │ │ │ ├── api-keys.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── metadata.tsx │ │ └── update │ │ │ └── index.tsx │ ├── routes.tsx │ ├── store │ │ ├── app.ts │ │ ├── compression.ts │ │ ├── settings.ts │ │ └── withStorageDOMEvents.ts │ ├── types │ │ └── global.d.ts │ ├── utils │ │ ├── NativeCompressor.ts │ │ ├── compressor.ts │ │ ├── fs-watch.ts │ │ ├── fs.ts │ │ ├── index.ts │ │ ├── launch.ts │ │ ├── logger.ts │ │ ├── menu.ts │ │ ├── notification.ts │ │ ├── scheduler.ts │ │ ├── tinify.ts │ │ ├── tray.ts │ │ ├── updater.ts │ │ └── window.ts │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── doc ├── Local-Compress&TinyPNG.png ├── Powerful-Batch-Processing.png ├── Watch-Mode.png ├── finder-compress.png ├── finder-watch.png └── logo.png ├── package-lock.json ├── package.json ├── packages └── picsharp-sidecar │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── constants.ts │ ├── controllers │ │ └── compress │ │ │ ├── avif.ts │ │ │ ├── gif.ts │ │ │ ├── jpeg.ts │ │ │ ├── png.ts │ │ │ ├── svg.ts │ │ │ ├── tiff.ts │ │ │ ├── tinify.ts │ │ │ └── webp.ts │ ├── get-port.ts │ ├── index.ts │ ├── services │ │ └── convert.ts │ └── utils.ts │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "privatePackages":{ 12 | "version": true, 13 | "tag": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{ts,tsx}] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Share an idea or suggestion 4 | title: 'FR: describing your feature request' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Does your feature request involve difficulty for you to complete a task? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you'd like to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any additional context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/report-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Report a bug 3 | about: Report a bug or a functional regression 4 | title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling' 5 | labels: ['type: bug'] 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | A clear and concise description of what the current behavior is. 12 | Please also add **screenshots** of the existing application. 13 | 14 | **Example:** 15 | 16 | ``` 17 | In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner 18 | [screenshot] 19 | ``` 20 | 21 | ## Expected behavior 22 | 23 | A clear and concise description of what the expected behavior is. 24 | 25 | **Example:** 26 | 27 | ``` 28 | The blank square should be transparent (invisible) 29 | ``` 30 | 31 | ## Technical inputs 32 | 33 | Operating System: 34 | 35 | PicSharp Version: 36 | 37 | **Example:** 38 | 39 | ``` 40 | Operating System: macOS 15.3.1 41 | PicSharp Version: 1.0.0 42 | We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper. 43 | Probably fixable with CSS 44 | ``` 45 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Keep GitHub Actions up to date with GitHub's Dependabot... 2 | # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 3 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem 4 | version: 2 5 | updates: 6 | - package-ecosystem: github-actions 7 | directory: / 8 | groups: 9 | github-actions: 10 | patterns: 11 | - "*" # Group all Actions updates into a single larger pull request 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .cursorrules 26 | .cursorignore 27 | 28 | # testing 29 | /coverage 30 | 31 | # misc 32 | *.pem 33 | /certs 34 | 35 | # local env files 36 | .env*.local 37 | 38 | # Rust build 39 | target -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 100, 4 | "semi": true, 5 | "tabWidth": 2, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "plugins": ["prettier-plugin-tailwindcss"] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] 3 | } 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "apps/picsharp-app/src-tauri", 4 | ] 5 | resolver = "2" 6 | 7 | [workspace.package] 8 | authors = ["JaylenLee117"] 9 | license = "AGPL-3.0" 10 | homepage = "https://github.com/AkiraBit/PicSharp" 11 | repository = "https://github.com/AkiraBit/PicSharp" 12 | categories = [] 13 | edition = "2021" 14 | rust-version = "1.82.0" -------------------------------------------------------------------------------- /apps/picsharp-app/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /apps/picsharp-app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | PicSharp 9 | 24 | 34 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /apps/picsharp-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@picsharp/picsharp-app", 3 | "private": true, 4 | "version": "1.2.1", 5 | "type": "module", 6 | "author": "AkiraBit", 7 | "license": "AGPL-3.0", 8 | "repository": "AkiraBit/PicSharp", 9 | "homepage": "https://github.com/AkiraBit/PicSharp", 10 | "bugs": { 11 | "url": "https://github.com/AkiraBit/PicSharp/issues" 12 | }, 13 | "scripts": { 14 | "dev": "vite", 15 | "build": "tsc && vite build", 16 | "build:macos-app": "pnpm tauri build --bundles app", 17 | "preview": "vite preview", 18 | "tauri": "tauri" 19 | }, 20 | "dependencies": { 21 | "@ant-design/icons": "^5.5.2", 22 | "@fontsource/ibm-plex-mono": "^5.1.1", 23 | "@hookform/resolvers": "^3.10.0", 24 | "@radix-ui/react-alert-dialog": "^1.1.6", 25 | "@radix-ui/react-checkbox": "^1.1.3", 26 | "@radix-ui/react-dialog": "^1.1.5", 27 | "@radix-ui/react-dropdown-menu": "^2.1.5", 28 | "@radix-ui/react-hover-card": "^1.1.6", 29 | "@radix-ui/react-label": "^2.1.1", 30 | "@radix-ui/react-popover": "^1.1.5", 31 | "@radix-ui/react-progress": "^1.1.2", 32 | "@radix-ui/react-scroll-area": "^1.2.8", 33 | "@radix-ui/react-select": "^2.1.5", 34 | "@radix-ui/react-separator": "^1.1.1", 35 | "@radix-ui/react-slot": "^1.1.1", 36 | "@radix-ui/react-switch": "^1.1.3", 37 | "@radix-ui/react-toolbar": "^1.1.2", 38 | "@radix-ui/react-tooltip": "^1.2.3", 39 | "@radix-ui/themes": "^3.1.6", 40 | "@tanstack/react-table": "^8.20.6", 41 | "@tauri-apps/api": "^2", 42 | "@tauri-apps/plugin-autostart": "~2.3.0", 43 | "@tauri-apps/plugin-cli": "~2", 44 | "@tauri-apps/plugin-clipboard-manager": "^2.2.1", 45 | "@tauri-apps/plugin-deep-link": "~2", 46 | "@tauri-apps/plugin-dialog": "~2", 47 | "@tauri-apps/plugin-fs": "~2", 48 | "@tauri-apps/plugin-http": "~2", 49 | "@tauri-apps/plugin-log": "~2", 50 | "@tauri-apps/plugin-notification": "~2", 51 | "@tauri-apps/plugin-opener": "^2", 52 | "@tauri-apps/plugin-os": "~2", 53 | "@tauri-apps/plugin-process": "~2", 54 | "@tauri-apps/plugin-shell": "~2", 55 | "@tauri-apps/plugin-store": "~2", 56 | "@tauri-apps/plugin-updater": "~2", 57 | "@tauri-apps/plugin-upload": "~2", 58 | "ahooks": "^3.8.4", 59 | "antd": "^5.22.5", 60 | "class-variance-authority": "^0.7.1", 61 | "clsx": "^2.1.1", 62 | "cmdk": "1.0.0", 63 | "dayjs": "^1.11.13", 64 | "eventemitter3": "^5.0.1", 65 | "i18next": "^24.2.2", 66 | "i18next-browser-languagedetector": "^8.0.4", 67 | "lucide-react": "^0.509.0", 68 | "marked": "^15.0.12", 69 | "next-themes": "^0.4.4", 70 | "radash": "^12.1.0", 71 | "radix-ui": "^1.1.3", 72 | "react": "^18.3.1", 73 | "react-color": "^2.19.3", 74 | "react-compare-slider": "^3.1.0", 75 | "react-dom": "^18.3.1", 76 | "react-hook-form": "^7.54.2", 77 | "react-i18next": "^15.4.0", 78 | "react-router": "^7.6.0", 79 | "sonner": "^1.7.2", 80 | "tailwind-merge": "^2.6.0", 81 | "tailwindcss-animate": "^1.0.7", 82 | "zod": "^3.24.1", 83 | "zustand": "^5.0.2" 84 | }, 85 | "devDependencies": { 86 | "@tauri-apps/cli": "^2", 87 | "@types/react": "^18.3.18", 88 | "@types/react-color": "^3.0.13", 89 | "@types/react-dom": "^18.3.5", 90 | "@vitejs/plugin-react": "^4.3.4", 91 | "autoprefixer": "^10.4.20", 92 | "postcss": "^8.4.49", 93 | "tailwindcss": "^3.4.16", 94 | "typescript": "^5", 95 | "vite": "^6.0.3" 96 | }, 97 | "engines": { 98 | "node": ">=20" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /apps/picsharp-app/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/picsharp-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/public/favicon.ico -------------------------------------------------------------------------------- /apps/picsharp-app/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/public/logo.png -------------------------------------------------------------------------------- /apps/picsharp-app/release-notes.json: -------------------------------------------------------------------------------- 1 | { 2 | "releases": { 3 | "1.2.1": { 4 | "date": "2025-06-09", 5 | "notes": [ 6 | "🐛 Fixed the problem that some color blocks in compressed images may deviate from their original colors due to unreasonable control of the color quantization degree of the PNG encoder" 7 | ] 8 | }, 9 | "1.2.0": { 10 | "date": "2025-06-08", 11 | "notes": [ 12 | "🆕 Supports converting images to specified formats when compressing.", 13 | "🚀 Optimize interactive experience.", 14 | "🐛 Fix some known bugs." 15 | ] 16 | }, 17 | "1.1.6": { 18 | "date": "2025-06-05", 19 | "notes": [ 20 | "Fix the known problems." 21 | ] 22 | }, 23 | "1.1.5": { 24 | "date": "2025-06-05", 25 | "notes": [ 26 | "Fix the known problems.", 27 | "Optimize the automatic update process of applications under the Windows system." 28 | ] 29 | }, 30 | "1.1.4": { 31 | "date": "2025-06-05", 32 | "notes": [ 33 | "Fix the known problems.", 34 | "Optimize application interaction." 35 | ] 36 | }, 37 | "1.1.3": { 38 | "date": "2025-06-04", 39 | "notes": [ 40 | "Add undo operation after image compression.", 41 | "Add application menu." 42 | ] 43 | }, 44 | "1.1.2": { 45 | "date": "2025-06-02", 46 | "notes": [ 47 | "Optimize file list parsing logic in app launch arguments across different operating systems.", 48 | "Upgrade Sharp to 0.34.2 and resolve libvips cache race condition when overwriting input files on Windows" 49 | ] 50 | }, 51 | "1.1.1": { 52 | "date": "2025-06-01", 53 | "notes": [ 54 | "Fix: resolve file drag-and-drop to app icon not responding on Windows systems.", 55 | "Fix: resolve resource path parsing issues on Windows systems" 56 | ] 57 | }, 58 | "1.1.0": { 59 | "date": "2025-06-01", 60 | "notes": [ 61 | "Add single instance mode support and optimize first-time config initialization.", 62 | "Sync theme changes across multiple windows.", 63 | "Sync settings changes across multiple windows and trigger updates.", 64 | "Fix some bugs." 65 | ] 66 | }, 67 | "1.0.5": { 68 | "date": "2025-05-25", 69 | "notes": [ 70 | "Fix some bugs.", 71 | "Add some common options to the system tray.", 72 | "Optimize the relase workflow.", 73 | "Add support for Windows and Linux (Ubuntu).", 74 | "Optimize application interaction." 75 | ] 76 | }, 77 | "1.0.4": { 78 | "date": "2025-05-22", 79 | "notes": [ 80 | "Optimize UI interaction", 81 | "Fix some bugs" 82 | ] 83 | }, 84 | "1.0.3": { 85 | "date": "2025-05-19", 86 | "notes": [ 87 | "Update UI components and add feedback page" 88 | ] 89 | }, 90 | "1.0.2": { 91 | "date": "2025-05-17", 92 | "notes": [ 93 | "Update release test" 94 | ] 95 | }, 96 | "1.0.1": { 97 | "date": "2025-05-17", 98 | "notes": [ 99 | "Update release test" 100 | ] 101 | }, 102 | "1.0.0": { 103 | "date": "2025-05-17", 104 | "notes": [ 105 | "First release" 106 | ] 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | 9 | /binaries -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "PicSharp" 3 | version = "1.0.1" 4 | description = "A simple, efficient and flexible cross-platform desktop image compression application." 5 | authors = ["JaylenL"] 6 | edition = "2021" 7 | default-run = "PicSharp" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [lib] 12 | # The `_lib` suffix may seem redundant but it is necessary 13 | # to make the lib name unique and wouldn't conflict with the bin name. 14 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 15 | name = "picsharp_lib" 16 | crate-type = ["staticlib", "cdylib", "rlib"] 17 | 18 | [build-dependencies] 19 | tauri-build = { version = "2", features = ["config-json5"] } 20 | 21 | [dependencies] 22 | glob = "0.3.1" 23 | tauri = { version = "2", features = ["protocol-asset", "tray-icon", "devtools"] } 24 | tauri-plugin-opener = "2" 25 | serde = { version = "1", features = ["derive"] } 26 | serde_json = "1" 27 | tauri-plugin-dialog = "2" 28 | tauri-plugin-fs = { version = "2.0.0", features = ["watch"] } 29 | tauri-plugin-log = "2" 30 | tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } 31 | tauri-plugin-upload = "2" 32 | tauri-plugin-os = "2" 33 | tauri-plugin-store = "2" 34 | log = "0.4" 35 | tauri-plugin-clipboard-manager = "2.2.1" 36 | tauri-plugin-notification = "2" 37 | notify = "^8" 38 | rayon = "1.10.0" 39 | walkdir = "2.5.0" 40 | urlencoding = "2.1.3" 41 | dunce = "1.0.5" 42 | filesize = "0.2.0" 43 | reqwest = { version = "0.12.12", default-features = false, features = ["json", "stream"] } 44 | tokio = { version = "1", features = ["fs"] } 45 | tokio-util = { version = "0.7", features = ["codec"] } 46 | futures-util = "0.3" 47 | read-progress-stream = "1.0.0" 48 | thiserror = "2.0.12" 49 | base64 = "0.21.7" 50 | rand = "0.8.5" 51 | # 图像处理相关依赖 52 | image = "0.25.6" 53 | # webp = "0.2.6" 54 | # webp-animation = "0.9.0" 55 | # ravif = "0.11.11" 56 | # imagequant = "4.3.4" 57 | # mozjpeg = "0.10.13" 58 | # png = "0.17.16" 59 | # rgb = "0.8.50" 60 | # oxipng = "9.1.4" 61 | tauri-plugin-deep-link = "2" 62 | url = "2.5.4" 63 | arboard = "3.5.0" 64 | clap = { version = "4.5", features = ["derive"] } 65 | clap_derive = { version = "4" } 66 | nanoid = "0.4.0" 67 | merge = "0.2.0" 68 | tauri-plugin-shell = "2" 69 | tauri-plugin-process = "2" 70 | 71 | [build] 72 | rustc-wrapper = "~/.cargo/bin/sccache" 73 | 74 | # TODO: cleanup features on objc2 crates 75 | [target.'cfg(target_os = "macos")'.dependencies] 76 | objc2-uniform-type-identifiers = { version = "0.3.0", features = [ 77 | "UTCoreTypes", 78 | "UTType", 79 | ] } 80 | objc2-app-kit = { version = "0.3.0", features = [ 81 | "NSApplication", 82 | "NSResponder", 83 | "NSPasteboard", 84 | ] } 85 | objc2-foundation = { version = "0.3.0", features = [ 86 | "NSExtensionRequestHandling", 87 | "NSExtensionContext", 88 | "NSExtensionItem", 89 | "NSArray", 90 | "NSItemProvider", 91 | "NSDictionary", 92 | "NSError", 93 | "NSObject", 94 | "NSString", 95 | "block2", 96 | "NSThread", 97 | "NSRunLoop", 98 | ] } 99 | objc2 = "0.6.0" 100 | 101 | [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 102 | tauri-plugin-autostart = "2" 103 | tauri-plugin-cli = "2" 104 | tauri-plugin-single-instance = "2" 105 | tauri-plugin-updater = "2" 106 | 107 | [[bin]] 108 | name = "picsharp-cli" 109 | path = "bin/cli.rs" 110 | default = true 111 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/Entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | 8 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeExtensions 9 | 10 | CFBundleTypeName 11 | Folder 12 | CFBundleTypeOSTypes 13 | 14 | TEXT 15 | utxt 16 | TUTX 17 | **** 18 | 19 | CFBundleTypeRole 20 | Editor 21 | LSItemContentTypes 22 | 23 | public.folder 24 | 25 | 26 | 27 | NSServices 28 | 29 | 30 | NSMenuItem 31 | 32 | default 33 | PicSharp • 图片压缩 34 | 35 | NSMessage 36 | nsCompress 37 | NSPortName 38 | PicSharp 39 | NSRequiredContext 40 | 41 | NSTextContent 42 | FilePath 43 | 44 | NSSendFileTypes 45 | 46 | public.png 47 | public.jpeg 48 | public.jpg 49 | public.JPG 50 | public.webp 51 | public.avif 52 | public.tiff 53 | public.tif 54 | public.gif 55 | public.svg 56 | public.folder 57 | 58 | 59 | 60 | NSMenuItem 61 | 62 | default 63 | PicSharp • 监控新增图片并压缩 64 | 65 | NSMessage 66 | nsWatchAndCompress 67 | NSPortName 68 | PicSharp 69 | NSRequiredContext 70 | 71 | NSTextContent 72 | FilePath 73 | 74 | NSSendFileTypes 75 | 76 | public.folder 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main", 7 | "PicSharp-*", 8 | "update-detail", 9 | "settings" 10 | ], 11 | "remote": { 12 | "urls": ["https://*"] 13 | }, 14 | "permissions": [ 15 | { 16 | "identifier": "http:default", 17 | "allow": [{ "url": "https://api.tinify.com/shrink" }, { "url": "http://localhost:*" }] 18 | }, 19 | "core:app:allow-default-window-icon", 20 | "core:window:allow-set-focus", 21 | "core:window:allow-destroy", 22 | "core:default", 23 | "opener:default", 24 | "dialog:default", 25 | "fs:default", 26 | "fs:allow-app-write", 27 | "fs:allow-app-write-recursive", 28 | "fs:allow-copy-file", 29 | "fs:allow-app-meta", 30 | "fs:allow-app-meta-recursive", 31 | "fs:allow-app-read", 32 | "fs:allow-app-read-recursive", 33 | "fs:allow-appcache-meta", 34 | "fs:allow-appcache-meta-recursive", 35 | "fs:allow-appcache-read", 36 | "fs:allow-appcache-read-recursive", 37 | "fs:allow-lstat", 38 | "log:default", 39 | "http:default", 40 | "upload:default", 41 | "core:window:allow-set-size", 42 | "core:webview:allow-set-webview-size", 43 | "os:default", 44 | "core:webview:allow-create-webview", 45 | "core:webview:allow-webview-show", 46 | "core:window:allow-create", 47 | "core:window:allow-show", 48 | "core:window:allow-set-title", 49 | "core:window:allow-start-dragging", 50 | "store:default", 51 | { 52 | "identifier": "opener:allow-open-path", 53 | "allow": [ 54 | { 55 | "path": "**/*" 56 | } 57 | ] 58 | }, 59 | { 60 | "identifier": "opener:allow-reveal-item-in-dir", 61 | "allow": [ 62 | { 63 | "path": "**/*" 64 | } 65 | ] 66 | }, 67 | { 68 | "identifier": "fs:scope", 69 | "allow": [ 70 | { 71 | "path": "**/*" 72 | } 73 | ] 74 | }, 75 | { 76 | "identifier": "fs:allow-watch", 77 | "allow": [ 78 | { 79 | "path": "**/*" 80 | } 81 | ] 82 | }, 83 | { 84 | "identifier": "fs:allow-unwatch", 85 | "allow": [ 86 | { 87 | "path": "**/*" 88 | } 89 | ] 90 | }, 91 | "clipboard-manager:default", 92 | "clipboard-manager:allow-write-text", 93 | "clipboard-manager:allow-write-image", 94 | "clipboard-manager:allow-read-text", 95 | "clipboard-manager:allow-read-image", 96 | "notification:default", 97 | { 98 | "identifier": "shell:allow-spawn", 99 | "allow": [ 100 | { 101 | "name": "binaries/picsharp-sidecar", 102 | "sidecar": true 103 | } 104 | ] 105 | }, 106 | "shell:allow-kill", 107 | "shell:allow-execute", 108 | "shell:allow-open", 109 | "core:webview:allow-create-webview-window", 110 | "process:default", 111 | "process:allow-exit", 112 | "process:allow-restart" 113 | ] 114 | } -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/capabilities/desktop.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "desktop-capability", 3 | "platforms": [ 4 | "macOS", 5 | "windows", 6 | "linux" 7 | ], 8 | "windows": [ 9 | "main", 10 | "PicSharp-*", 11 | "update-detail", 12 | "settings" 13 | ], 14 | "permissions": [ 15 | "autostart:default", 16 | "autostart:default", 17 | "cli:default", 18 | "core:event:default", 19 | "deep-link:default", 20 | "updater:default" 21 | ] 22 | } -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/32x32_.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/32x32_.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/32x32__.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/32x32__.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/_128x128.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/_128x128@2x.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/_icon.ico -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/_icon.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/resources/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "", 3 | "autostart": false, 4 | "auto_check_update": true, 5 | "compression_mode": "local", 6 | "compression_type": "lossy", 7 | "compression_level": 4, 8 | "compression_output": "overwrite", 9 | "compression_output_save_as_file_suffix": "_compressed", 10 | "compression_output_save_to_folder": "", 11 | "compression_threshold_enable": false, 12 | "compression_threshold_value": 0.1, 13 | "tinypng_api_keys": [], 14 | "tinypng_preserve_metadata": [ 15 | "copyright", 16 | "creator", 17 | "location" 18 | ], 19 | "compression_convert": [], 20 | "compression_convert_alpha": "#FFFFFF" 21 | } -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/file_ext/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | #[path = "finder.rs"] 3 | mod platform; 4 | 5 | #[cfg(not(target_os = "macos"))] 6 | mod platform { 7 | use crate::Inspect; 8 | pub fn load(_inspect: Inspect) {} 9 | } 10 | 11 | pub use platform::*; 12 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/image_processor/compressors/avif.rs: -------------------------------------------------------------------------------- 1 | use crate::image_processor::common::CompressionError; 2 | use crate::image_processor::common::QualityMode; 3 | use image; 4 | use ravif::{AlphaColorMode, Encoder, Img}; 5 | use rgb::RGBA; 6 | use std::fs; 7 | use std::path::Path; 8 | 9 | pub fn lossless_compress_avif( 10 | input_path: &Path, 11 | output_path: &Path, 12 | ) -> Result<(), CompressionError> { 13 | let img = image::open(input_path) 14 | .map_err(|e| CompressionError::ImageProcessing(e.to_string()))? 15 | .to_rgba8(); 16 | 17 | let width = img.width() as usize; 18 | let height = img.height() as usize; 19 | 20 | let rgba_pixels: Vec> = img 21 | .into_raw() 22 | .chunks_exact(4) 23 | .map(|p| RGBA::new(p[0], p[1], p[2], p[3])) 24 | .collect(); 25 | 26 | let res = Encoder::new() 27 | .with_quality(100.0) 28 | .with_alpha_color_mode(AlphaColorMode::UnassociatedClean) 29 | .with_alpha_quality(100.0) 30 | .with_speed(4) 31 | .encode_rgba(Img::new(&rgba_pixels, width, height)) 32 | .map_err(|e| CompressionError::ImageProcessing(e.to_string()))?; 33 | 34 | fs::write(output_path, res.avif_file).map_err(|e| CompressionError::Io(e))?; 35 | 36 | Ok(()) 37 | } 38 | 39 | pub fn lossy_compress_avif( 40 | input_path: &Path, 41 | output_path: &Path, 42 | level: u8, 43 | ) -> Result<(), CompressionError> { 44 | let img = image::open(input_path) 45 | .map_err(|e| CompressionError::ImageProcessing(e.to_string()))? 46 | .to_rgba8(); 47 | 48 | log::info!("lossy_compress_avif: {:?} {:?}", input_path, output_path); 49 | 50 | let width = img.width() as usize; 51 | let height = img.height() as usize; 52 | 53 | let rgba = img.as_raw().to_vec(); 54 | let rgba_pixels: Vec> = rgba 55 | .chunks_exact(4) 56 | .map(|p| RGBA::new(p[0], p[1], p[2], p[3])) 57 | .collect(); 58 | 59 | let (quality, alpha_quality, speed) = match level { 60 | 1 => (95.0, 95.0, 1), 61 | 2 => (85.0, 90.0, 2), 62 | 3 => (75.0, 85.0, 3), 63 | 4 => (65.0, 80.0, 5), 64 | 5 => (55.0, 75.0, 7), 65 | 6 => (45.0, 70.0, 10), 66 | _ => (75.0, 85.0, 3), 67 | }; 68 | 69 | log::info!("AVIF压缩等级: {:?}, 质量: {:?}", level, quality); 70 | 71 | let res = Encoder::new() 72 | .with_quality(quality) 73 | .with_alpha_quality(alpha_quality) 74 | .with_speed(speed) 75 | .encode_rgba(Img::new(&rgba_pixels, width, height)) 76 | .map_err(|e| CompressionError::ImageProcessing(format!("AVIF encoding error: {}", e)))?; 77 | 78 | fs::write(output_path, res.avif_file).map_err(|e| CompressionError::Io(e))?; 79 | 80 | Ok(()) 81 | } 82 | 83 | pub fn compress_avif( 84 | input_path: &Path, 85 | output_path: &Path, 86 | level: u8, 87 | mode: Option, 88 | ) -> Result<(), CompressionError> { 89 | log::info!("compress_avif: {:?} {:?}", level, mode); 90 | 91 | match mode { 92 | Some(QualityMode::Lossless) => lossless_compress_avif(input_path, output_path), 93 | Some(QualityMode::Lossy) => lossy_compress_avif(input_path, output_path, level), 94 | None => lossy_compress_avif(input_path, output_path, level), 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/image_processor/compressors/jpeg.rs: -------------------------------------------------------------------------------- 1 | use crate::image_processor::common::CompressionError; 2 | use image; 3 | use mozjpeg; 4 | use std::fs; 5 | use std::path::Path; 6 | 7 | pub fn compress_jpeg( 8 | input_path: &Path, 9 | output_path: &Path, 10 | level: u8, 11 | ) -> Result<(), CompressionError> { 12 | let img = 13 | image::open(input_path).map_err(|e| CompressionError::ImageProcessing(e.to_string()))?; 14 | 15 | let rgb = img.to_rgb8(); 16 | let width = rgb.width() as usize; 17 | let height = rgb.height() as usize; 18 | let rgb_data = rgb.as_raw(); 19 | 20 | let mut comp = mozjpeg::Compress::new(mozjpeg::ColorSpace::JCS_RGB); 21 | 22 | comp.set_size(width, height); 23 | 24 | let quality = match level { 25 | 6 => 10.0, 26 | 5 => 30.0, 27 | 4 => 60.0, 28 | 3 => 75.0, 29 | 2 => 85.0, 30 | 1 => 100.0, 31 | _ => 75.0, 32 | }; 33 | 34 | comp.set_quality(quality); 35 | 36 | let buffer = std::panic::catch_unwind(|| -> std::io::Result> { 37 | let mut comp = comp.start_compress(Vec::new())?; 38 | comp.write_scanlines(rgb_data)?; 39 | let jpeg_data = comp.finish()?; 40 | Ok(jpeg_data) 41 | }) 42 | .map_err(|_| CompressionError::ImageProcessing("JPEG compression failed".to_string()))? 43 | .map_err(|e| CompressionError::ImageProcessing(e.to_string()))?; 44 | 45 | fs::write(output_path, buffer).map_err(|e| CompressionError::Io(e))?; 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/image_processor/compressors/svg.rs: -------------------------------------------------------------------------------- 1 | use crate::image_processor::common::CompressionError; 2 | use std::fs; 3 | use std::io::{self, Error, ErrorKind}; 4 | use std::path::Path; 5 | use std::process::Command; 6 | 7 | pub fn compress_svg(input_path: &Path, output_path: &Path) -> Result<(), CompressionError> { 8 | let svg_data = fs::read_to_string(input_path).map_err(|e| CompressionError::Io(e))?; 9 | 10 | // TODO: implement svg compression 11 | fs::write(output_path, svg_data).map_err(|e| CompressionError::Io(e))?; 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/image_processor/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod common; 2 | pub mod compressors; 3 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | picsharp_lib::run(); 6 | } 7 | -------------------------------------------------------------------------------- /apps/picsharp-app/src-tauri/tauri.conf.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.tauri.app/config/2", 3 | "productName": "PicSharp", 4 | "version": "../package.json", 5 | "identifier": "com.PicSharp.app", 6 | "build": { 7 | "beforeDevCommand": "pnpm dev", 8 | "devUrl": "http://localhost:1420", 9 | "beforeBuildCommand": "pnpm build", 10 | "frontendDist": "../dist" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "resizable": true, 16 | "titleBarStyle": "Overlay", 17 | "hiddenTitle": true, 18 | "title": "PicSharp", 19 | "width": 863, 20 | "height": 528, 21 | "center": true 22 | } 23 | ], 24 | "security": { 25 | "dangerousDisableAssetCspModification": true, 26 | // "csp": { 27 | // "default-src": "'self' customprotocol: asset:", 28 | // "connect-src": "ipc: http://ipc.localhost http://localhost", 29 | // "font-src": [ 30 | // "https://fonts.gstatic.com" 31 | // ], 32 | // "img-src": "'self' asset: http://asset.localhost blob: data:", 33 | // "style-src": "'unsafe-inline' 'self' https://fonts.googleapis.com" 34 | // }, 35 | "assetProtocol": { 36 | "enable": true, 37 | "scope": [ 38 | "**" 39 | ] 40 | } 41 | } 42 | }, 43 | "bundle": { 44 | "active": true, 45 | "targets": "all", 46 | "category": "Productivity", 47 | "copyright": "Copyright (c) AkiraBit 2025. All rights reserved.", 48 | "homepage": "https://github.com/AkiraBit/PicSharp", 49 | "icon": [ 50 | "icons/32x32.png", 51 | "icons/128x128.png", 52 | "icons/128x128@2x.png", 53 | "icons/icon.icns", 54 | "icons/icon.ico" 55 | ], 56 | "resources": [ 57 | "resources/**/*" 58 | ], 59 | "externalBin": [ 60 | "binaries/picsharp-sidecar" 61 | ], 62 | "windows": { 63 | "webviewInstallMode": { 64 | "type": "embedBootstrapper" 65 | } 66 | }, 67 | "macOS": { 68 | "entitlements": "./Entitlements.plist", 69 | "minimumSystemVersion": "12.0" 70 | }, 71 | "linux": { 72 | "deb": { 73 | "section": "text" 74 | } 75 | }, 76 | "fileAssociations": [ 77 | { 78 | "name": "image", 79 | "ext": [ 80 | "png", 81 | "jpg", 82 | "jpeg", 83 | "webp", 84 | "avif", 85 | "gif", 86 | "svg", 87 | "tiff" 88 | ], 89 | "description": "Image file", 90 | "mimeType": "image/*", 91 | "role": "Editor" 92 | } 93 | ], 94 | createUpdaterArtifacts: true 95 | }, 96 | "plugins": { 97 | "cli": { 98 | "description": "PicSharp CLI", 99 | "args": [ 100 | { 101 | "name": "file", 102 | "index": 1, 103 | "takesValue": true 104 | } 105 | ] 106 | }, 107 | "deep-link": { 108 | "desktop": { 109 | "schemes": ["picsharp"] 110 | } 111 | }, 112 | "updater": { 113 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU0Nzg5NjY2MDRFOTlCNjQKUldSa20ra0VacFo0VkswUU1WaE45WFFzN3YvYXFZMkQ4c0xVdHZJaVhMNFZmNHd1MHIrUjNQMHgK", 114 | "endpoints": ["https://github.com/AkiraBit/PicSharp/releases/latest/download/latest.json"] 115 | } 116 | } 117 | } -------------------------------------------------------------------------------- /apps/picsharp-app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import AppRoutes from './routes'; 2 | 3 | function App() { 4 | return ; 5 | } 6 | 7 | export default App; 8 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/assets/logo-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src/assets/logo-96x96.png -------------------------------------------------------------------------------- /apps/picsharp-app/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/apps/picsharp-app/src/assets/logo.png -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/background-pattern.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | function BackgroundPattern() { 3 | return ( 4 |
5 | 6 | 7 | 13 | 19 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | ); 38 | } 39 | 40 | export default memo(BackgroundPattern); 41 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/checkbox-group.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Checkbox } from '@/components/ui/checkbox'; 3 | import { Label } from '@/components/ui/label'; 4 | import { cn } from '@/lib/utils'; 5 | 6 | interface CheckboxGroupOption { 7 | value: string; 8 | label: React.ReactNode; 9 | disabled?: boolean; 10 | } 11 | 12 | interface CheckboxGroupProps { 13 | options: CheckboxGroupOption[]; 14 | value?: string[]; 15 | defaultValue?: string[]; 16 | onChange?: (value: string[]) => void; 17 | name?: string; 18 | disabled?: boolean; 19 | className?: string; 20 | itemClassName?: string; 21 | labelClassName?: string; 22 | } 23 | 24 | export function CheckboxGroup({ 25 | options, 26 | value: controlledValue, 27 | defaultValue, 28 | onChange, 29 | name, 30 | disabled = false, 31 | className, 32 | itemClassName, 33 | labelClassName, 34 | }: CheckboxGroupProps) { 35 | const [internalValue, setInternalValue] = useState(defaultValue || []); 36 | 37 | const isControlled = controlledValue !== undefined; 38 | const currentValue = isControlled ? controlledValue : internalValue; 39 | 40 | const handleCheckedChange = (optionValue: string, checked: boolean) => { 41 | let newValue: string[]; 42 | if (checked) { 43 | newValue = [...currentValue, optionValue]; 44 | } else { 45 | newValue = currentValue.filter((v) => v !== optionValue); 46 | } 47 | 48 | if (!isControlled) { 49 | setInternalValue(newValue); 50 | } 51 | onChange?.(newValue); 52 | }; 53 | 54 | return ( 55 |
56 | {options.map((option) => { 57 | const isChecked = currentValue.includes(option.value); 58 | const isDisabled = disabled || option.disabled; 59 | const id = name ? `${name}-${option.value}` : option.value; 60 | 61 | return ( 62 |
63 | { 69 | handleCheckedChange(option.value, !!checked); 70 | }} 71 | disabled={isDisabled} 72 | aria-labelledby={`${id}-label`} 73 | /> 74 | 81 |
82 | ); 83 | })} 84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/data-table/data-table-column-header.tsx: -------------------------------------------------------------------------------- 1 | import { Column } from '@tanstack/react-table'; 2 | import { Button } from '@/components/ui/button'; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuSeparator, 8 | DropdownMenuTrigger, 9 | } from '@/components/ui/dropdown-menu'; 10 | import { EyeOff, ChevronsUpDown, ArrowUp, ArrowDown } from 'lucide-react'; 11 | 12 | import { cn } from '@/lib/utils'; 13 | 14 | interface DataTableColumnHeaderProps extends React.HTMLAttributes { 15 | column: Column; 16 | title: string; 17 | } 18 | 19 | export function DataTableColumnHeader({ 20 | column, 21 | title, 22 | className, 23 | }: DataTableColumnHeaderProps) { 24 | if (!column.getCanSort()) { 25 | return
{title}
; 26 | } 27 | 28 | return ( 29 |
30 | 31 | 32 | 42 | 43 | 44 | column.toggleSorting(false)}> 45 | 46 | Asc 47 | 48 | column.toggleSorting(true)}> 49 | 50 | Desc 51 | 52 | {!!column.getCanHide() && ( 53 | <> 54 | 55 | column.toggleVisibility(false)}> 56 | 57 | Hide 58 | 59 | 60 | )} 61 | 62 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/data-table/data-table-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { 3 | Select, 4 | SelectContent, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import type { Table } from '@tanstack/react-table'; 10 | import { ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react'; 11 | 12 | interface DataTablePaginationProps { 13 | table: Table; 14 | } 15 | 16 | export function DataTablePagination({ table }: DataTablePaginationProps) { 17 | return ( 18 |
19 |
20 | {table.getFilteredRowModel().rows.length} row(s). 21 |
22 |
23 |
24 |

Rows per page

25 | 42 |
43 |
44 | Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} 45 |
46 |
47 | 56 | 65 | 74 | 83 |
84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/data-table/data-table-toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Table } from '@tanstack/react-table'; 2 | import { Button } from '@/components/ui/button'; 3 | import { Input } from '@/components/ui/input'; 4 | import { X as Close } from 'lucide-react'; 5 | import { DataTableFacetedFilter } from './data-table-faceted-filter'; 6 | import type { DataTableFacetedFilterProps } from './data-table-faceted-filter'; 7 | import { useEffect, useState } from 'react'; 8 | 9 | interface DataTableToolbarProps { 10 | table: Table; 11 | searchBy?: string; 12 | filters?: DataTableFacetedFilterProps[]; 13 | } 14 | 15 | export function DataTableToolbar({ 16 | table, 17 | searchBy, 18 | filters, 19 | }: DataTableToolbarProps) { 20 | const isFiltered = table.getState().columnFilters.length > 0 || table.getState().globalFilter; 21 | 22 | return ( 23 |
24 |
25 | {searchBy && ( 26 | table.setGlobalFilter(value)} 30 | className="h-8 w-[150px] lg:w-[250px]" 31 | /> 32 | )} 33 | {filters?.map((filter) => ( 34 | 35 | id={filter.id} 36 | key={filter.id} 37 | column={table.getColumn(filter.id!)} 38 | title={filter.title} 39 | options={filter.options} 40 | /> 41 | ))} 42 | {isFiltered && ( 43 | 54 | )} 55 |
56 |
57 | ); 58 | } 59 | 60 | function SearchInput({ 61 | value: initialValue, 62 | onChange, 63 | debounceTime = 800, 64 | ...props 65 | }: { 66 | value: string | number; 67 | onChange: (value: string | number) => void; 68 | debounceTime?: number; 69 | } & Omit, 'onChange'>) { 70 | const [value, setValue] = useState(initialValue); 71 | 72 | useEffect(() => { 73 | setValue(initialValue); 74 | }, [initialValue]); 75 | 76 | const handleKeyDown = (e: any) => { 77 | if (e.key === 'Enter') { 78 | onChange(value); // Invoke onChange with the current value 79 | } 80 | }; 81 | 82 | const handleBlur = () => { 83 | onChange(value); // Invoke onChange with the current value 84 | }; 85 | 86 | return ( 87 | setValue(e.target.value)} 91 | onKeyDown={handleKeyDown} 92 | onBlur={handleBlur} 93 | /> 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/dropdown-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChevronDown, Loader } from "lucide-react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuGroup, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | interface DropdownButtonProps 14 | extends React.ButtonHTMLAttributes { 15 | items?: { 16 | key: string; 17 | label: React.ReactNode; 18 | onClick?: () => void; 19 | disabled?: boolean; 20 | danger?: boolean; 21 | }[]; 22 | onClick?: () => void; 23 | trigger?: React.ReactNode; 24 | disabled?: boolean; 25 | loading?: boolean; 26 | placement?: "bottomLeft" | "bottomRight" | "topLeft" | "topRight"; 27 | icon?: React.ReactNode; 28 | size?: "default" | "sm" | "lg"; 29 | buttonType?: 30 | | "default" 31 | | "destructive" 32 | | "outline" 33 | | "secondary" 34 | | "ghost" 35 | | "link"; 36 | className?: string; 37 | dropdownClassName?: string; 38 | children?: React.ReactNode; 39 | } 40 | 41 | export const DropdownButton = React.forwardRef< 42 | HTMLDivElement, 43 | DropdownButtonProps 44 | >( 45 | ( 46 | { 47 | items = [], 48 | onClick, 49 | trigger, 50 | disabled = false, 51 | loading = false, 52 | placement = "bottomRight", 53 | icon = , 54 | size = "default", 55 | buttonType = "default", 56 | className, 57 | dropdownClassName, 58 | children, 59 | ...props 60 | }, 61 | ref 62 | ) => { 63 | const getPlacementClass = () => { 64 | switch (placement) { 65 | case "bottomLeft": 66 | return "origin-top-left left-0"; 67 | case "topLeft": 68 | return "origin-bottom-left bottom-full left-0 mb-2"; 69 | case "topRight": 70 | return "origin-bottom-right bottom-full right-0 mb-2"; 71 | case "bottomRight": 72 | default: 73 | return "origin-top-right right-0"; 74 | } 75 | }; 76 | 77 | return ( 78 |
79 | 90 | 91 | 92 | 99 | 100 | 107 | 108 | {items.map((item) => ( 109 | 115 | {item.label} 116 | 117 | ))} 118 | 119 | 120 | 121 |
122 | ); 123 | } 124 | ); 125 | 126 | DropdownButton.displayName = "DropdownButton"; 127 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo, ReactNode } from 'react'; 2 | import { Button } from 'antd'; 3 | import { Translation } from 'react-i18next'; 4 | import * as logger from '@tauri-apps/plugin-log'; 5 | 6 | interface Props { 7 | children: ReactNode; 8 | } 9 | 10 | interface State { 11 | hasError: boolean; 12 | error?: Error; 13 | } 14 | 15 | export default class ErrorBoundary extends Component { 16 | public state: State = { 17 | hasError: false, 18 | error: undefined, 19 | }; 20 | 21 | public static getDerivedStateFromError(error: Error): State { 22 | return { hasError: true, error }; 23 | } 24 | 25 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 26 | logger.error(`Error Boundary Caught Error: 27 | Message: ${error.message} 28 | Stack: ${error.stack} 29 | Component Stack: ${errorInfo.componentStack} 30 | `); 31 | } 32 | 33 | public render() { 34 | if (this.state.hasError) { 35 | return ( 36 |
37 |
38 | {/* */} 39 |

40 | {/* @ts-ignore */} 41 | {(t) => t('error.something_went_wrong')} 42 |

43 |

44 | {/* @ts-ignore */} 45 | {(t) => t('error.unexpected_error')} 46 |

47 | 51 |
52 |
53 | ); 54 | } 55 | 56 | return this.props.children; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/fullscreen-progress.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useImperativeHandle, useRef } from 'react'; 2 | import * as ProgressPrimitive from '@radix-ui/react-progress'; 3 | import { cn } from '@/lib/utils'; 4 | 5 | export interface PageProgressRef { 6 | show: (ease?: boolean) => void; 7 | done: () => void; 8 | reset: () => void; 9 | setValue: (value: number) => void; 10 | } 11 | 12 | function easeOutCirc(x: number): number { 13 | return Math.sqrt(1 - Math.pow(x - 1, 2)); 14 | } 15 | 16 | const PageProgress = forwardRef< 17 | PageProgressRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, value, ...props }, ref) => { 20 | const rootRef = useRef(null); 21 | const indicatorRef = useRef(null); 22 | const timerRef = useRef(null); 23 | const isDoneRef = useRef(false); 24 | 25 | const reset = () => { 26 | cancelAnimationFrame(timerRef.current); 27 | isDoneRef.current = false; 28 | if (indicatorRef.current) { 29 | indicatorRef.current.style.transform = `translateX(-100%)`; 30 | } 31 | rootRef.current?.classList.add('hidden'); 32 | rootRef.current?.parentElement?.style.removeProperty('position'); 33 | rootRef.current?.parentElement?.style.removeProperty('overflow'); 34 | }; 35 | 36 | useImperativeHandle(ref, () => { 37 | return { 38 | show: (ease: boolean = false) => { 39 | if (!rootRef.current) return; 40 | rootRef.current?.classList.remove('hidden'); 41 | rootRef.current.parentElement?.style.setProperty('position', 'relative'); 42 | rootRef.current.parentElement?.style.setProperty('overflow', 'hidden'); 43 | if (ease) { 44 | let progress = 0; 45 | const startTime = performance.now(); 46 | const increment = (currentTime: number) => { 47 | if (indicatorRef.current) { 48 | const elapsedTime = (currentTime - startTime) / 1000; // 转换为秒 49 | const maxTime = 60; // 最大时间100秒 50 | const t = Math.min(elapsedTime / maxTime, 1); // 计算进度时间比例 51 | progress = easeOutCirc(t) * 100; // 使用easeOutCirc函数计算进度 52 | if (progress < 100 && !isDoneRef.current) { 53 | indicatorRef.current.style.transform = `translateX(-${100 - progress}%)`; 54 | timerRef.current = requestAnimationFrame(increment); 55 | } 56 | } 57 | }; 58 | timerRef.current = requestAnimationFrame(increment); 59 | } 60 | }, 61 | done: () => { 62 | isDoneRef.current = true; 63 | if (indicatorRef.current) { 64 | indicatorRef.current.style.transform = `translateX(0%)`; 65 | } 66 | setTimeout(reset, 500); 67 | }, 68 | reset, 69 | setValue: (value: number) => { 70 | if (indicatorRef.current) { 71 | indicatorRef.current.style.transform = `translateX(-${100 - value}%)`; 72 | } 73 | }, 74 | }; 75 | }); 76 | 77 | return ( 78 |
82 |
89 |
94 |
95 |
96 | ); 97 | }); 98 | 99 | PageProgress.displayName = 'PageProgress'; 100 | 101 | export { PageProgress }; 102 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/img-tag/index.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { Badge } from '@/components/ui/badge'; 3 | 4 | export interface ImgTagProps { 5 | type: string; 6 | } 7 | 8 | function ImgTag(props: ImgTagProps) { 9 | const { type } = props; 10 | switch (type) { 11 | case 'png': 12 | return PNG; 13 | case 'jpg': 14 | return JPG; 15 | case 'jpeg': 16 | return JPEG; 17 | case 'webp': 18 | return WEBP; 19 | case 'avif': 20 | return AVIF; 21 | default: 22 | return null; 23 | } 24 | } 25 | 26 | export default memo(ImgTag); 27 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/link.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation, Link as Link2, LinkProps } from 'react-router'; 2 | import { useNavigate } from '@/hooks/useNavigate'; 3 | import { isString, isFunction } from 'radash'; 4 | import { forwardRef } from 'react'; 5 | 6 | const Link = forwardRef, LinkProps>((props, ref) => { 7 | const { children, to, viewTransition = true, onClick, ...restProps } = props; 8 | const navigate = useNavigate(); 9 | const location = useLocation(); 10 | 11 | const handleClick = async (event: React.MouseEvent) => { 12 | isFunction(onClick) && onClick(event); 13 | event.preventDefault(); 14 | const toPath = isString(to) ? to : to.pathname; 15 | if (location.pathname.startsWith(toPath)) { 16 | return; 17 | } 18 | if (isFunction(document.startViewTransition)) { 19 | document.startViewTransition(() => {}); 20 | } 21 | navigate(to); 22 | }; 23 | 24 | return ( 25 | } 27 | to={to} 28 | viewTransition={viewTransition} 29 | onClick={handleClick} 30 | > 31 | {children} 32 | 33 | ); 34 | }); 35 | 36 | export default Link; 37 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useRef, useState } from 'react'; 2 | import { ConfigProvider, theme as antdTheme } from 'antd'; 3 | 4 | export enum Theme { 5 | Dark = 'dark', 6 | Light = 'light', 7 | System = 'system', 8 | } 9 | 10 | type ThemeProviderProps = { 11 | children: React.ReactNode; 12 | defaultTheme?: Theme; 13 | storageKey?: string; 14 | }; 15 | 16 | type ThemeProviderState = { 17 | theme: Theme; 18 | setTheme: (theme: Theme) => void; 19 | }; 20 | 21 | const initialState: ThemeProviderState = { 22 | theme: Theme.System, 23 | setTheme: () => null, 24 | }; 25 | 26 | const ThemeProviderContext = createContext(initialState); 27 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 28 | 29 | export function ThemeProvider({ 30 | children, 31 | defaultTheme = Theme.System, 32 | storageKey = 'app-theme', 33 | ...props 34 | }: ThemeProviderProps) { 35 | const [theme, setTheme] = useState( 36 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, 37 | ); 38 | const themeRef = useRef(theme); 39 | 40 | const algorithm = { 41 | [Theme.Dark]: antdTheme.darkAlgorithm, 42 | [Theme.Light]: antdTheme.defaultAlgorithm, 43 | [Theme.System]: mediaQuery.matches ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, 44 | }; 45 | 46 | function setThemeStyle(newTheme: Theme.Dark | Theme.Light) { 47 | const root = window.document.documentElement; 48 | root.classList.remove('light', 'dark'); 49 | root.classList.add(newTheme); 50 | } 51 | 52 | function toggleTheme(newTheme: Theme) { 53 | if (newTheme === Theme.System) { 54 | const systemTheme = mediaQuery.matches ? Theme.Dark : Theme.Light; 55 | setThemeStyle(systemTheme); 56 | } else { 57 | setThemeStyle(newTheme); 58 | } 59 | localStorage.setItem(storageKey, newTheme); 60 | setTheme(newTheme); 61 | themeRef.current = newTheme; 62 | } 63 | 64 | useEffect(() => { 65 | function handleThemeChange(event: MediaQueryListEvent) { 66 | if (themeRef.current !== Theme.System) return; 67 | if (event.matches) { 68 | setThemeStyle(Theme.Dark); 69 | } else { 70 | setThemeStyle(Theme.Light); 71 | } 72 | } 73 | const handleStorageChange = (event: StorageEvent) => { 74 | if (event.key === storageKey) { 75 | toggleTheme(event.newValue as Theme); 76 | } 77 | }; 78 | window.addEventListener('storage', handleStorageChange); 79 | mediaQuery.addEventListener('change', handleThemeChange); 80 | const currentTheme = (localStorage.getItem(storageKey) as Theme) || defaultTheme; 81 | if (currentTheme === Theme.System) { 82 | setThemeStyle(mediaQuery.matches ? Theme.Dark : Theme.Light); 83 | } else { 84 | setThemeStyle(currentTheme); 85 | } 86 | return () => { 87 | mediaQuery.removeEventListener('change', handleThemeChange); 88 | window.removeEventListener('storage', handleStorageChange); 89 | }; 90 | }, []); 91 | 92 | return ( 93 | 100 | 105 | {children} 106 | 107 | 108 | ); 109 | } 110 | 111 | export const useTheme = () => { 112 | const context = useContext(ThemeProviderContext); 113 | 114 | if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); 115 | 116 | return context; 117 | }; 118 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-md border border-neutral-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 dark:border-neutral-800 dark:focus:ring-neutral-300', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'border-transparent bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/80 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/80', 13 | secondary: 14 | 'border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80', 15 | third: 16 | 'border-transparent bg-neutral-200/60 text-neutral-900 dark:bg-neutral-600 dark:text-neutral-50 px-1.5', 17 | 'third-mini': 18 | 'border-transparent bg-neutral-200/60 text-neutral-900 dark:bg-neutral-600 dark:text-neutral-50 py-[1px] px-1', 19 | destructive: 20 | 'border-transparent bg-red-500 text-neutral-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/80', 21 | outline: 'text-neutral-950 dark:text-neutral-50', 22 | blue: 'border-blue-300 !bg-blue-50 !text-blue-700 dark:border-blue-800 dark:!bg-blue-950 dark:!text-blue-300', 23 | green: 24 | 'border-green-300 !bg-green-50 !text-green-700 dark:border-green-800 dark:!bg-green-950 dark:!text-green-300', 25 | cyan: 'border-cyan-300 !bg-cyan-50 !text-cyan-700 dark:border-cyan-800 dark:!bg-cyan-950 dark:!text-cyan-300', 26 | purple: 27 | 'border-purple-300 !bg-purple-50 !text-purple-700 dark:border-purple-800 dark:!bg-purple-950 dark:!text-purple-300', 28 | success: 29 | 'border-green-300 !bg-green-50 !text-green-700 dark:border-green-800 dark:!bg-green-950 dark:!text-green-300', 30 | warning: 31 | 'border-yellow-300 !bg-yellow-50 !text-yellow-700 dark:border-yellow-800 dark:!bg-yellow-950 dark:!text-yellow-300', 32 | error: 33 | 'border-red-300 !bg-red-50 !text-red-700 dark:border-red-800 dark:!bg-red-950 dark:!text-red-300', 34 | processing: 35 | 'border-blue-300 !bg-blue-50 !text-blue-700 dark:border-blue-800 dark:!bg-blue-950 dark:!text-blue-300', 36 | gray: 'border-gray-300 !bg-gray-50 !text-gray-700 dark:border-gray-800 dark:!bg-gray-950 dark:!text-gray-300', 37 | minor: 38 | 'border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-700 dark:text-neutral-200', 39 | mini: 'border-transparent bg-neutral-900 text-neutral-50 shadow dark:bg-neutral-50 dark:text-neutral-900 py-[1px] px-1', 40 | }, 41 | }, 42 | defaultVariants: { 43 | variant: 'default', 44 | }, 45 | }, 46 | ); 47 | 48 | export interface BadgeProps 49 | extends React.HTMLAttributes, 50 | VariantProps {} 51 | 52 | function Badge({ className, variant, ...props }: BadgeProps) { 53 | return
; 54 | } 55 | 56 | export { Badge, badgeVariants }; 57 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-300 dark:text-neutral-900 dark:hover:bg-neutral-100', 14 | destructive: 15 | 'bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90', 16 | outline: 17 | 'border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-600 dark:bg-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50', 18 | secondary: 19 | 'bg-neutral-200/80 text-neutral-900 shadow-sm hover:bg-neutral-300/50 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80', 20 | ghost: 21 | 'hover:bg-neutral-200/60 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50', 22 | link: 'text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50', 23 | }, 24 | size: { 25 | default: 'h-9 px-4 py-2', 26 | sm: 'h-8 rounded-md px-3 text-xs', 27 | lg: 'h-10 rounded-md px-8', 28 | icon: 'h-9 w-9', 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: 'default', 33 | size: 'default', 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : 'button'; 47 | return ( 48 | 49 | ); 50 | }, 51 | ); 52 | Button.displayName = 'Button'; 53 | 54 | export { Button, buttonVariants }; 55 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
15 | ), 16 | ); 17 | Card.displayName = 'Card'; 18 | 19 | const CardHeader = React.forwardRef>( 20 | ({ className, ...props }, ref) => ( 21 |
22 | ), 23 | ); 24 | CardHeader.displayName = 'CardHeader'; 25 | 26 | const CardTitle = React.forwardRef>( 27 | ({ className, ...props }, ref) => ( 28 |
33 | ), 34 | ); 35 | CardTitle.displayName = 'CardTitle'; 36 | 37 | const CardDescription = React.forwardRef>( 38 | ({ className, ...props }, ref) => ( 39 |
44 | ), 45 | ); 46 | CardDescription.displayName = 'CardDescription'; 47 | 48 | const CardContent = React.forwardRef>( 49 | ({ className, ...props }, ref) => ( 50 |
51 | ), 52 | ); 53 | CardContent.displayName = 'CardContent'; 54 | 55 | const CardFooter = React.forwardRef>( 56 | ({ className, ...props }, ref) => ( 57 |
58 | ), 59 | ); 60 | CardFooter.displayName = 'CardFooter'; 61 | 62 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; 63 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const HoverCard = HoverCardPrimitive.Root 7 | 8 | const HoverCardTrigger = HoverCardPrimitive.Trigger 9 | 10 | const HoverCardContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 24 | )) 25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 26 | 27 | export { HoverCard, HoverCardTrigger, HoverCardContent } 28 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | }, 19 | ); 20 | Input.displayName = 'Input'; 21 | 22 | export { Input }; 23 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 32 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ProgressPrimitive from "@radix-ui/react-progress" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Progress = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, value, ...props }, ref) => ( 10 | 18 | 22 | 23 | )) 24 | Progress.displayName = ProgressPrimitive.Root.displayName 25 | 26 | export { Progress } 27 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )); 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 28 | 39 | 40 | 41 | )); 42 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 43 | 44 | export { ScrollArea, ScrollBar }; 45 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ) 27 | Separator.displayName = SeparatorPrimitive.Root.displayName 28 | 29 | export { Separator } 30 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | import { Toaster as Sonner } from "sonner" 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 3 | 4 | import { cn } from '@/lib/utils'; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0 dark:bg-neutral-800/50", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px] dark:text-neutral-400", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SETTINGS_FILE_NAME = 'settings.json'; 2 | 3 | export const DEFAULT_SETTINGS_FILE_NAME = 'settings.default.json'; 4 | 5 | export const VALID_TINYPNG_IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'webp', 'avif']; 6 | 7 | export const VALID_IMAGE_EXTS = [...VALID_TINYPNG_IMAGE_EXTS, 'svg', 'gif', 'tiff', 'tif']; 8 | 9 | export const VALID_IMAGE_MIME_TYPES = { 10 | png: 'image/png', 11 | jpg: 'image/jpeg', 12 | jpeg: 'image/jpeg', 13 | webp: 'image/webp', 14 | avif: 'image/avif', 15 | svg: 'image/svg+xml', 16 | gif: 'image/gif', 17 | tiff: 'image/tiff', 18 | tif: 'image/tiff', 19 | }; 20 | 21 | export enum SettingsKey { 22 | Language = 'language', 23 | Autostart = 'autostart', 24 | AutoCheckUpdate = 'auto_check_update', 25 | CompressionMode = 'compression_mode', 26 | CompressionType = 'compression_type', 27 | CompressionLevel = 'compression_level', 28 | Concurrency = 'concurrency', 29 | CompressionThresholdEnable = 'compression_threshold_enable', 30 | CompressionThresholdValue = 'compression_threshold_value', 31 | CompressionOutput = 'compression_output', 32 | CompressionOutputSaveAsFileSuffix = 'compression_output_save_as_file_suffix', 33 | CompressionOutputSaveToFolder = 'compression_output_save_to_folder', 34 | CompressionConvert = 'compression_convert', 35 | CompressionConvertAlpha = 'compression_convert_alpha', 36 | TinypngApiKeys = 'tinypng_api_keys', 37 | TinypngPreserveMetadata = 'tinypng_preserve_metadata', 38 | } 39 | 40 | export enum CompressionMode { 41 | Auto = 'auto', 42 | Remote = 'remote', 43 | Local = 'local', 44 | } 45 | 46 | export enum CompressionType { 47 | Lossless = 'lossless', 48 | Lossy = 'lossy', 49 | } 50 | 51 | export enum CompressionOutputMode { 52 | Overwrite = 'overwrite', 53 | SaveAsNewFile = 'save_as_new_file', 54 | SaveToNewFolder = 'save_to_new_folder', 55 | } 56 | 57 | export enum TinypngMetadata { 58 | Copyright = 'copyright', 59 | Creator = 'creator', 60 | Location = 'location', 61 | } 62 | 63 | export enum ConvertFormat { 64 | Avif = 'avif', 65 | Webp = 'webp', 66 | Jpg = 'jpg', 67 | Png = 'png', 68 | } 69 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/hooks/useNavigate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useNavigate as useNavigate2, 3 | useLocation, 4 | NavigateOptions as NavigateOptions2, 5 | To, 6 | } from 'react-router'; 7 | import useCompressionStore from '@/store/compression'; 8 | import { useCallback } from 'react'; 9 | import { isString, isObject } from 'radash'; 10 | import { useI18n } from '@/i18n'; 11 | import { createWebviewWindow } from '@/utils/window'; 12 | import message from '@/components/message'; 13 | 14 | export const blockCompressionRoutes = [ 15 | '/compression/classic/workspace', 16 | '/compression/watch/workspace', 17 | ]; 18 | 19 | export interface NavigateOptions extends NavigateOptions2 { 20 | confirm?: boolean; 21 | } 22 | 23 | export function useNavigate() { 24 | const navigate = useNavigate2(); 25 | const location = useLocation(); 26 | const t = useI18n(); 27 | 28 | return useCallback( 29 | async (url: To, options: NavigateOptions = {}) => { 30 | const { confirm = true } = options; 31 | const state = useCompressionStore.getState(); 32 | let nextUrl = ''; 33 | if (isString(url)) { 34 | nextUrl = url; 35 | } 36 | if (isObject(url)) { 37 | nextUrl = url.pathname; 38 | } 39 | 40 | if (url === '/settings' && state.working) { 41 | createWebviewWindow('settings', { 42 | url, 43 | title: t('nav.settings'), 44 | width: 796, 45 | height: 528, 46 | center: true, 47 | resizable: true, 48 | titleBarStyle: 'overlay', 49 | hiddenTitle: true, 50 | dragDropEnabled: true, 51 | minimizable: true, 52 | maximizable: true, 53 | }); 54 | return; 55 | } 56 | 57 | if (blockCompressionRoutes.includes(location.pathname) && state.inCompressing) { 58 | message.warning({ 59 | title: t('tips.please_wait_for_compression_to_finish'), 60 | }); 61 | return; 62 | } 63 | 64 | if (blockCompressionRoutes.includes(location.pathname) && state.working) { 65 | if (confirm) { 66 | const answer = await message.confirm({ 67 | title: t('tips.are_you_sure_to_exit'), 68 | }); 69 | if (!answer) return; 70 | } 71 | state.reset(); 72 | } 73 | navigate(url, options); 74 | }, 75 | [navigate, location], 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/hooks/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { pick } from 'radash'; 2 | import { useRef } from 'react'; 3 | import { shallow } from 'zustand/shallow'; 4 | 5 | type Pick = { 6 | [P in K]: T[P]; 7 | }; 8 | 9 | type Many = T | readonly T[]; 10 | 11 | export default function useSelector( 12 | paths: Many

13 | ): (state: S) => Pick { 14 | const prev = useRef>({} as Pick); 15 | 16 | return (state: S) => { 17 | if (state) { 18 | const next = pick(state, paths as any); 19 | return shallow(prev.current, next) ? prev.current : (prev.current = next); 20 | } 21 | return prev.current; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import enUS from './locales/en-US'; 2 | import zhCN from './locales/zh-CN'; 3 | import i18next from 'i18next'; 4 | import { initReactI18next, useTranslation } from 'react-i18next'; 5 | import LanguageDetector from 'i18next-browser-languagedetector'; 6 | import { createTrayMenu } from '@/utils/tray'; 7 | import { initAppMenu } from '@/utils/menu'; 8 | import type { TOptions } from 'i18next'; 9 | import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; 10 | import { platform } from '@tauri-apps/plugin-os'; 11 | 12 | declare module 'i18next' { 13 | interface CustomTypeOptions { 14 | resources: { 15 | 'en-US': typeof enUS; 16 | 'zh-CN': typeof zhCN; 17 | }; 18 | returnNull: false; 19 | } 20 | } 21 | 22 | export const useI18n = () => { 23 | const { t } = useTranslation(); 24 | return (key: keyof typeof enUS, options?: TOptions) => { 25 | // @ts-ignore 26 | return t(key, options); 27 | }; 28 | }; 29 | 30 | // 导出非React环境下可直接使用的t函数 31 | export const t = (key: keyof typeof enUS, options?: Record) => { 32 | return i18next.t(key as string, options); 33 | }; 34 | 35 | i18next 36 | .use(initReactI18next) 37 | .use(LanguageDetector) 38 | .init({ 39 | supportedLngs: ['en-US', 'zh-CN'], 40 | fallbackLng: { 41 | default: ['en-US', 'zh-CN'], 42 | }, 43 | resources: { 44 | 'en-US': { translation: enUS }, 45 | 'zh-CN': { translation: zhCN }, 46 | }, 47 | interpolation: { 48 | escapeValue: false, 49 | }, 50 | react: { 51 | useSuspense: false, 52 | }, 53 | detection: { 54 | order: ['localStorage', 'navigator'], 55 | caches: ['localStorage'], 56 | }, 57 | }); 58 | 59 | i18next.on('languageChanged', async (lng) => { 60 | if (getCurrentWebviewWindow().label === 'main') { 61 | if (platform() === 'macos') { 62 | initAppMenu(); 63 | } 64 | const menu = await createTrayMenu(); 65 | window.__TRAY_INSTANCE?.setMenu(menu); 66 | } 67 | }); 68 | 69 | export default i18next; 70 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './utils/tray'; 2 | import './utils/menu'; 3 | import './i18n'; 4 | import './store/settings'; 5 | import ReactDOM from 'react-dom/client'; 6 | import App from './App'; 7 | import './index.css'; 8 | 9 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); 10 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/classic-file-manager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useMemo } from 'react'; 2 | import FileCard from './file-card'; 3 | import useCompressionStore from '@/store/compression'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import Toolbar from './toolbar'; 6 | import ToolbarPagination from './toolbar-pagination'; 7 | import { Empty } from 'antd'; 8 | import { isValidArray, preventDefault } from '@/utils'; 9 | import { useNavigate } from '@/hooks/useNavigate'; 10 | import { useI18n } from '@/i18n'; 11 | 12 | function FileManager() { 13 | const { files } = useCompressionStore(useSelector(['files'])); 14 | const [pageIndex, setPageIndex] = useState(1); 15 | const [pageSize, setPageSize] = useState(100); 16 | const navigate = useNavigate(); 17 | const t = useI18n(); 18 | const dataList = useMemo(() => { 19 | let list = files.slice((pageIndex - 1) * pageSize, pageIndex * pageSize); 20 | if (list.length === 0 && pageIndex !== 1) { 21 | list = files.slice((pageIndex - 2) * pageSize, (pageIndex - 1) * pageSize); 22 | setPageIndex(pageIndex - 1); 23 | } 24 | return list; 25 | }, [files, pageIndex, pageSize]); 26 | 27 | useEffect(() => { 28 | const state = useCompressionStore.getState(); 29 | if (!isValidArray(state.files)) { 30 | state.reset(); 31 | navigate('/compression/classic/guide'); 32 | } 33 | }, []); 34 | 35 | return ( 36 |

37 | {isValidArray(dataList) ? ( 38 |
39 |
45 | {dataList.map((file) => ( 46 | 47 | ))} 48 |
49 |
50 | ) : ( 51 |
52 | 53 |
54 | )} 55 |
56 | {files.length > pageSize && ( 57 | { 62 | if (pageIndex) { 63 | setPageIndex(pageIndex); 64 | } 65 | if (pageSize) { 66 | setPageSize(pageSize); 67 | } 68 | }} 69 | /> 70 | )} 71 | 72 |
73 |
74 | ); 75 | } 76 | 77 | export default FileManager; 78 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/classic.tsx: -------------------------------------------------------------------------------- 1 | import FileManager from "./classic-file-manager"; 2 | 3 | function CompressionClassic() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | 11 | export default CompressionClassic; 12 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, createContext } from 'react'; 2 | import { Outlet } from 'react-router'; 3 | import { PageProgress, PageProgressRef } from '@/components/fullscreen-progress'; 4 | 5 | export const CompressionContext = createContext<{ 6 | progressRef: React.RefObject; 7 | }>({ 8 | progressRef: null, 9 | }); 10 | 11 | export default function Compression() { 12 | const progressRef = useRef(null); 13 | return ( 14 | 15 |
16 | 17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/toolbar-exit.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useContext } from 'react'; 2 | import { Button } from '@/components/ui/button'; 3 | import { LogOut } from 'lucide-react'; 4 | import useCompressionStore from '@/store/compression'; 5 | import useSelector from '@/hooks/useSelector'; 6 | import { useNavigate } from '@/hooks/useNavigate'; 7 | import { toast } from 'sonner'; 8 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; 9 | import { useI18n } from '@/i18n'; 10 | import { AppContext } from '@/routes'; 11 | 12 | function ToolbarExit(props: { mode: 'classic' | 'watch' }) { 13 | const navigate = useNavigate(); 14 | const { inCompressing } = useCompressionStore(useSelector(['inCompressing'])); 15 | const t = useI18n(); 16 | const { messageApi, notificationApi } = useContext(AppContext); 17 | const handleExit = () => { 18 | if (props.mode === 'classic') { 19 | navigate('/compression/classic/guide'); 20 | } else { 21 | navigate('/compression/watch/guide'); 22 | } 23 | toast.dismiss(); 24 | messageApi?.destroy(); 25 | notificationApi?.destroy(); 26 | }; 27 | 28 | return ( 29 | 30 | 31 | 40 | 41 | {t('quit')} 42 | 43 | ); 44 | } 45 | 46 | export default memo(ToolbarExit); 47 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/toolbar-info.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import useCompressionStore from '@/store/compression'; 3 | import useSelector from '@/hooks/useSelector'; 4 | import { Button } from '@/components/ui/button'; 5 | import { Separator } from '@/components/ui/separator'; 6 | import { Info } from 'lucide-react'; 7 | import { useI18n } from '@/i18n'; 8 | import { humanSize } from '@/utils/fs'; 9 | import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; 10 | import { correctFloat } from '@/utils'; 11 | 12 | const PopoverContent = () => { 13 | const { files } = useCompressionStore(useSelector(['files'])); 14 | const t = useI18n(); 15 | 16 | const originalSize = files.reduce((acc, file) => { 17 | return acc + (file.bytesSize || 0); 18 | }, 0); 19 | 20 | const compressedSize = files.reduce((acc, file) => { 21 | if (file.compressedBytesSize) { 22 | return acc + (file.compressedBytesSize || 0); 23 | } 24 | return acc; 25 | }, 0); 26 | 27 | const reducedSize = files.reduce((acc, file) => { 28 | if (file.compressedBytesSize) { 29 | return acc + ((file.bytesSize || 0) - (file.compressedBytesSize || 0)); 30 | } 31 | return acc; 32 | }, 0); 33 | 34 | const compressRate = reducedSize > 0 ? correctFloat((reducedSize / originalSize) * 100, 1) : '0'; 35 | 36 | return ( 37 |
38 |
39 |
{t('compression.toolbar.info.total_files')}
40 |
{files.length}
41 |
42 | 43 |
44 |
{t('compression.toolbar.info.total_original_size')}
45 |
{humanSize(originalSize)}
46 |
47 | 48 |
49 |
{t('compression.toolbar.info.total_saved_volume')}
50 |
{humanSize(compressedSize)}
51 |
52 | 53 |
54 |
{t('compression.toolbar.info.saved_volume_rate')}
55 |
{compressRate}%
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default memo(function ToolbarInfo() { 62 | return ( 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | }); 75 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/toolbar-pagination.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { Pagination } from 'antd'; 3 | 4 | export interface ToolbarPaginationProps { 5 | total: number; 6 | current: number; 7 | pageSize: number; 8 | onChange: (page: number, pageSize: number) => void; 9 | } 10 | 11 | export default memo(function ToolbarPagination(props: ToolbarPaginationProps) { 12 | const { total, current, pageSize, onChange } = props; 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/toolbar-select.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { memo } from "react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { ChevronDown, MousePointer2 } from "lucide-react"; 5 | import useCompressionStore from "@/store/compression"; 6 | import useSelector from "@/hooks/useSelector"; 7 | import type { MenuProps } from "antd"; 8 | import { Dropdown } from "antd"; 9 | import { IScheduler } from "@/utils/scheduler"; 10 | 11 | enum RowSelection { 12 | ALL = "all", 13 | INVERT = "invert", 14 | UNCOMPRESSED = "uncompressed", 15 | UNSAVED = "unsaved", 16 | } 17 | 18 | function ToolbarSelect() { 19 | const { files, selectedFiles, setSelectedFiles, inSaving, inCompressing } = 20 | useCompressionStore( 21 | useSelector([ 22 | "files", 23 | "selectedFiles", 24 | "setSelectedFiles", 25 | "inSaving", 26 | "inCompressing", 27 | ]) 28 | ); 29 | 30 | const items: MenuProps["items"] = [ 31 | { 32 | key: RowSelection.ALL, 33 | label: "All", 34 | disabled: inCompressing || inSaving, 35 | }, 36 | { 37 | key: RowSelection.INVERT, 38 | label: "Invert", 39 | disabled: inCompressing || inSaving, 40 | }, 41 | { 42 | key: RowSelection.UNCOMPRESSED, 43 | label: "Uncompressed", 44 | disabled: inCompressing || inSaving, 45 | }, 46 | { 47 | key: RowSelection.UNSAVED, 48 | label: "Unsaved", 49 | disabled: inCompressing || inSaving, 50 | }, 51 | ]; 52 | 53 | const handleSelectMode: MenuProps["onClick"] = ({ key }) => { 54 | switch (key) { 55 | case RowSelection.ALL: 56 | setSelectedFiles(files.map((file) => file.id)); 57 | break; 58 | case RowSelection.INVERT: 59 | setSelectedFiles( 60 | files 61 | .filter((file) => !selectedFiles.includes(file.id)) 62 | .map((file) => file.id) 63 | ); 64 | break; 65 | case RowSelection.UNCOMPRESSED: 66 | setSelectedFiles( 67 | files 68 | .filter( 69 | (file) => file.compressStatus === IScheduler.TaskStatus.Pending 70 | ) 71 | .map((file) => file.id) 72 | ); 73 | break; 74 | case RowSelection.UNSAVED: 75 | setSelectedFiles( 76 | files 77 | .filter( 78 | (file) => file.compressStatus === IScheduler.TaskStatus.Completed 79 | ) 80 | .map((file) => file.id) 81 | ); 82 | break; 83 | } 84 | }; 85 | 86 | return ( 87 | 88 | 94 | 95 | ); 96 | } 97 | 98 | export default memo(ToolbarSelect); 99 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import ToolbarCompress from './toolbar-compress'; 3 | import { Separator } from '@/components/ui/separator'; 4 | import ToolbarReset from './toolbar-exit'; 5 | import ToolbarInfo from './toolbar-info'; 6 | import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; 7 | import { TooltipProvider } from '@/components/ui/tooltip'; 8 | export interface ToolbarProps { 9 | mode: 'classic' | 'watch'; 10 | } 11 | 12 | function Toolbar(props: ToolbarProps) { 13 | const { mode } = props; 14 | return ( 15 | 16 |
17 |
18 | 19 | {mode === 'classic' && ( 20 | <> 21 | 22 | 23 | 24 | )} 25 | {getCurrentWebviewWindow().label === 'main' && ( 26 | <> 27 | 28 | 29 | 30 | )} 31 |
32 |
33 |
34 | ); 35 | } 36 | 37 | export default memo(Toolbar); 38 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/compression/watch-file-manager.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useState, useMemo, useReducer } from 'react'; 2 | import FileCard from './file-card'; 3 | import useCompressionStore from '@/store/compression'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import Toolbar from './toolbar'; 6 | import ToolbarPagination from './toolbar-pagination'; 7 | import { isValidArray } from '@/utils'; 8 | import { Disc3 } from 'lucide-react'; 9 | import { useI18n } from '../../i18n'; 10 | import { Badge } from '@/components/ui/badge'; 11 | export interface WatchFileManagerProps {} 12 | 13 | function WatchFileManager(props: WatchFileManagerProps) { 14 | const { files, watchingFolder } = useCompressionStore(useSelector(['files', 'watchingFolder'])); 15 | const t = useI18n(); 16 | 17 | const [pageIndex, setPageIndex] = useState(1); 18 | const [pageSize, setPageSize] = useState(100); 19 | 20 | const dataList = useMemo(() => { 21 | let list = files.slice((pageIndex - 1) * pageSize, pageIndex * pageSize); 22 | if (list.length === 0 && pageIndex !== 1) { 23 | list = files.slice((pageIndex - 2) * pageSize, (pageIndex - 1) * pageSize); 24 | setPageIndex(pageIndex - 1); 25 | } 26 | return list; 27 | }, [files, pageIndex, pageSize]); 28 | 29 | return ( 30 |
31 | 35 | {watchingFolder} 36 | 37 | {isValidArray(dataList) ? ( 38 |
39 |
45 | {dataList.map((file) => ( 46 | 47 | ))} 48 |
49 |
50 | ) : ( 51 |
52 | 53 |
54 | )} 55 |
56 | {files.length > pageSize && ( 57 | { 62 | if (pageIndex) { 63 | setPageIndex(pageIndex); 64 | } 65 | if (pageSize) { 66 | setPageSize(pageSize); 67 | } 68 | }} 69 | /> 70 | )} 71 | 72 |
73 |
74 | ); 75 | } 76 | 77 | export default memo(WatchFileManager); 78 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/image-compare/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactCompareSlider, ReactCompareSliderImage } from 'react-compare-slider'; 2 | import { parseOpenWithFiles } from '@/utils/launch'; 3 | import { Badge } from '@/components/ui/badge'; 4 | import { useI18n } from '@/i18n'; 5 | import { isMac } from '@/utils'; 6 | import { cn } from '@/lib/utils'; 7 | 8 | const launchPayload = parseOpenWithFiles(); 9 | export default function ImageCompare() { 10 | const file = launchPayload?.mode === 'compress:compare' ? launchPayload?.file : null; 11 | const t = useI18n(); 12 | 13 | if (!file) return null; 14 | 15 | return ( 16 |
17 |
22 |
23 | 24 | {file?.name} 25 | 26 | -{file?.compressRate} 27 |
28 |
29 |
30 |
31 |
32 | {file && ( 33 | 48 | } 49 | itemTwo={ 50 | 59 | } 60 | /> 61 | )} 62 |
63 |
64 | {t('beforeCompression')} 65 |
66 |
67 | {file?.formattedBytesSize}{' '} 68 |
69 |
70 |
71 |
72 | {t('afterCompression')} 73 |
74 |
75 | {file?.formattedCompressedBytesSize} 76 |
77 |
78 |
79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/about/feedback.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useI18n } from '@/i18n'; 3 | import SettingItem from '../setting-item'; 4 | import { Button } from '@/components/ui/button'; 5 | import { ChevronRight } from 'lucide-react'; 6 | 7 | function SettingsAboutVersion() { 8 | const t = useI18n(); 9 | return ( 10 | 14 | 15 | 18 | 19 | 20 | ); 21 | } 22 | 23 | export default memo(SettingsAboutVersion); 24 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card'; 2 | import Section from '../section'; 3 | import { memo } from 'react'; 4 | import Version from './version'; 5 | import Feedback from './feedback'; 6 | export default memo(function SettingsTinypng() { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/about/version.tsx: -------------------------------------------------------------------------------- 1 | import packageJson from '@/../package.json'; 2 | import { memo, useState, useContext } from 'react'; 3 | import { useI18n } from '@/i18n'; 4 | import SettingItem from '../setting-item'; 5 | import { Button } from '@/components/ui/button'; 6 | import checkUpdate from '@/utils/updater'; 7 | import { Loader2 } from 'lucide-react'; 8 | import { Trans } from 'react-i18next'; 9 | import { AppContext } from '@/routes'; 10 | 11 | function SettingsAboutVersion() { 12 | const t = useI18n(); 13 | const [isChecking, setIsChecking] = useState(false); 14 | const { messageApi } = useContext(AppContext); 15 | const handleCheckUpdate = async () => { 16 | try { 17 | setIsChecking(true); 18 | const updater = await checkUpdate(); 19 | setIsChecking(false); 20 | if (!updater) { 21 | messageApi?.success(t('settings.about.version.no_update_available')); 22 | } 23 | } catch (error) { 24 | setIsChecking(false); 25 | messageApi?.error(t('settings.about.version.check_update_failed')); 26 | console.error(error); 27 | } 28 | }; 29 | 30 | return ( 31 | 44 | ), 45 | }} 46 | > 47 | } 48 | > 49 | 53 | 54 | ); 55 | } 56 | 57 | export default memo(SettingsAboutVersion); 58 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/concurrency.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useI18n } from '@/i18n'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { Input } from '@/components/ui/input'; 6 | import { SettingsKey } from '@/constants'; 7 | import SettingItem from '../setting-item'; 8 | 9 | export default memo(function SettingsConcurrency() { 10 | const t = useI18n(); 11 | const { concurrency, set } = useSettingsStore(useSelector([SettingsKey.Concurrency, 'set'])); 12 | 13 | const handleValueChange = (e: React.ChangeEvent) => { 14 | const value = Number(e.target.value); 15 | set(SettingsKey.Concurrency, value); 16 | }; 17 | 18 | return ( 19 | 23 | 31 | 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/convert.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useI18n } from '@/i18n'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { SettingsKey, ConvertFormat } from '@/constants'; 6 | import SettingItem from '../setting-item'; 7 | import { CheckboxGroup } from '@/components/checkbox-group'; 8 | import { Badge } from '@/components/ui/badge'; 9 | import { ColorPicker, ColorPickerProps } from 'antd'; 10 | 11 | function SettingsCompressionConvert() { 12 | const t = useI18n(); 13 | const { 14 | compression_convert: convertTypes = [], 15 | compression_convert_alpha: color = '#FFFFFF', 16 | set, 17 | } = useSettingsStore( 18 | useSelector([SettingsKey.CompressionConvert, SettingsKey.CompressionConvertAlpha, 'set']), 19 | ); 20 | 21 | const options = [ 22 | { 23 | value: ConvertFormat.Png, 24 | label: 'PNG', 25 | }, 26 | { 27 | value: ConvertFormat.Jpg, 28 | label: 'JPG', 29 | }, 30 | { 31 | value: ConvertFormat.Avif, 32 | label: 'AVIF', 33 | }, 34 | { 35 | value: ConvertFormat.Webp, 36 | label: 'WebP', 37 | }, 38 | ]; 39 | 40 | const handleValueChange = (value: string[]) => { 41 | set(SettingsKey.CompressionConvert, value); 42 | }; 43 | 44 | const handleColorChange: ColorPickerProps['onChange'] = (color) => { 45 | set(SettingsKey.CompressionConvertAlpha, color.toHexString()); 46 | }; 47 | 48 | return ( 49 | <> 50 | 53 | {t('settings.compression.convert.title')} 54 | {t(`settings.compression.mode.option.local`)} 55 | TinyPNG 56 | 57 | } 58 | titleClassName='flex flex-row items-center gap-x-2' 59 | description={t('settings.compression.convert.description')} 60 | > 61 | 62 | 63 | 66 | {t('settings.compression.convert_alpha.title')} 67 | {t(`settings.compression.mode.option.local`)} 68 | TinyPNG 69 | 70 | } 71 | titleClassName='flex flex-row items-center gap-x-2' 72 | description={t('settings.compression.convert_alpha.description')} 73 | > 74 | 82 | 83 | 84 | ); 85 | } 86 | 87 | export default memo(SettingsCompressionConvert); 88 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card'; 2 | import Mode from './mode'; 3 | import Section from '../section'; 4 | import Output from './output'; 5 | import Threshold from './threshold'; 6 | import Type from './type'; 7 | import Level from './level'; 8 | import { useEffect, useRef } from 'react'; 9 | import Convert from './convert'; 10 | 11 | export default function SettingsCompression() { 12 | const outputElRef = useRef(null); 13 | 14 | useEffect(() => { 15 | const hash = window.location.hash; 16 | if (outputElRef.current && hash === '#output') { 17 | setTimeout(() => { 18 | outputElRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); 19 | outputElRef.current.classList.add('breathe-highlight'); 20 | }, 300); 21 | } 22 | }, []); 23 | 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/level.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import { useI18n } from '@/i18n'; 10 | import { memo } from 'react'; 11 | import useSettingsStore from '@/store/settings'; 12 | import useSelector from '@/hooks/useSelector'; 13 | import { SettingsKey, CompressionType } from '@/constants'; 14 | import SettingItem from '../setting-item'; 15 | import { Badge } from '@/components/ui/badge'; 16 | export default memo(function SettingsCompressionLevel() { 17 | const t = useI18n(); 18 | const { compression_level: level, set } = useSettingsStore( 19 | useSelector([SettingsKey.CompressionLevel, 'set']), 20 | ); 21 | 22 | const options = [ 23 | { 24 | value: '1', 25 | label: t('settings.compression.level.option.1'), 26 | }, 27 | { 28 | value: '2', 29 | label: t('settings.compression.level.option.2'), 30 | }, 31 | { 32 | value: '3', 33 | label: t('settings.compression.level.option.3'), 34 | }, 35 | { 36 | value: '4', 37 | label: t('settings.compression.level.option.4'), 38 | }, 39 | { 40 | value: '5', 41 | label: t('settings.compression.level.option.5'), 42 | }, 43 | ]; 44 | 45 | const handleChange = async (value: string) => { 46 | await set(SettingsKey.CompressionLevel, Number(value)); 47 | }; 48 | 49 | return ( 50 | 53 | {t('settings.compression.level.title')} 54 | {t(`settings.compression.mode.option.local`)} 55 | 56 | } 57 | titleClassName='flex flex-row items-center gap-x-2' 58 | description={t('settings.compression.level.description')} 59 | > 60 | 74 | 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/mode.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import { useI18n } from '@/i18n'; 10 | import { memo } from 'react'; 11 | import useSettingsStore from '@/store/settings'; 12 | import useSelector from '@/hooks/useSelector'; 13 | import { SettingsKey, CompressionMode as Mode } from '@/constants'; 14 | import SettingItem from '../setting-item'; 15 | 16 | export default memo(function SettingsCompressionMode() { 17 | const t = useI18n(); 18 | const { compression_mode: mode, set } = useSettingsStore( 19 | useSelector([SettingsKey.CompressionMode, 'set']), 20 | ); 21 | 22 | const modes = [ 23 | { 24 | value: Mode.Auto, 25 | label: t('settings.compression.mode.option.auto'), 26 | }, 27 | { 28 | value: Mode.Remote, 29 | label: t('settings.compression.mode.option.remote'), 30 | }, 31 | { 32 | value: Mode.Local, 33 | label: t('settings.compression.mode.option.local'), 34 | }, 35 | ]; 36 | 37 | const handleChange = async (value: string) => { 38 | await set(SettingsKey.CompressionMode, value); 39 | }; 40 | 41 | return ( 42 | 46 | 60 | 61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/threshold.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useI18n } from '@/i18n'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { Input } from '@/components/ui/input'; 6 | import { SettingsKey } from '@/constants'; 7 | import { Switch } from '@/components/ui/switch'; 8 | import SettingItem from '../setting-item'; 9 | import { correctFloat } from '@/utils'; 10 | 11 | export default memo(function SettingsCompressionThreshold() { 12 | const t = useI18n(); 13 | const { 14 | compression_threshold_enable: enable, 15 | compression_threshold_value: value, 16 | set, 17 | } = useSettingsStore( 18 | useSelector([ 19 | SettingsKey.CompressionThresholdEnable, 20 | SettingsKey.CompressionThresholdValue, 21 | 'set', 22 | ]), 23 | ); 24 | 25 | const handleCheckedChange = (checked: boolean) => { 26 | set(SettingsKey.CompressionThresholdEnable, checked); 27 | }; 28 | 29 | const handleValueChange = (e: React.ChangeEvent) => { 30 | let value = Number(e.target.value); 31 | if (value > 99) { 32 | value = 99; 33 | } else if (value < 1) { 34 | value = 1; 35 | } 36 | set(SettingsKey.CompressionThresholdValue, correctFloat(value / 100)); 37 | }; 38 | 39 | return ( 40 | 45 |
46 | 47 | 57 | % 58 |
59 |
60 | ); 61 | }); 62 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/compression/type.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import { useI18n } from '@/i18n'; 10 | import { memo } from 'react'; 11 | import useSettingsStore from '@/store/settings'; 12 | import useSelector from '@/hooks/useSelector'; 13 | import { SettingsKey, CompressionType } from '@/constants'; 14 | import SettingItem from '../setting-item'; 15 | import { Badge } from '@/components/ui/badge'; 16 | 17 | export default memo(function SettingsCompressionType() { 18 | const t = useI18n(); 19 | const { compression_type: type, set } = useSettingsStore( 20 | useSelector([SettingsKey.CompressionType, 'set']), 21 | ); 22 | 23 | const options = [ 24 | { 25 | value: CompressionType.Lossless, 26 | label: t('settings.compression.type.option.lossless'), 27 | }, 28 | { 29 | value: CompressionType.Lossy, 30 | label: t('settings.compression.type.option.lossy'), 31 | }, 32 | ]; 33 | 34 | const handleChange = async (value: string) => { 35 | await set(SettingsKey.CompressionType, value); 36 | }; 37 | 38 | return ( 39 | 42 | {t('settings.compression.type.title')} 43 | {t(`settings.compression.mode.option.local`)} 44 | 45 | } 46 | titleClassName='flex flex-row items-center gap-x-2' 47 | description={t(`settings.compression.type.description.${type}`)} 48 | > 49 | 63 | 64 | ); 65 | }); 66 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/general/autostart.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/i18n'; 2 | import { memo } from 'react'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { SettingsKey } from '@/constants'; 6 | import { Switch } from '@/components/ui/switch'; 7 | import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart'; 8 | import { toast } from 'sonner'; 9 | import { useAsyncEffect } from 'ahooks'; 10 | import SettingItem from '../setting-item'; 11 | 12 | export default memo(function SettingsGeneralAutostart() { 13 | const t = useI18n(); 14 | const { autostart, set } = useSettingsStore(useSelector([SettingsKey.Autostart, 'set'])); 15 | 16 | const handleChangeAutostart = async (value: boolean) => { 17 | try { 18 | if (value) { 19 | await enable(); 20 | } else { 21 | await disable(); 22 | } 23 | set(SettingsKey.Autostart, value); 24 | } catch (error) { 25 | toast.error(t('tips.autostart.error')); 26 | } 27 | }; 28 | 29 | useAsyncEffect(async () => { 30 | const enable = await isEnabled(); 31 | set(SettingsKey.Autostart, enable); 32 | }, []); 33 | 34 | return ( 35 | 39 | 40 | 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/general/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card'; 2 | import Autostart from './autostart'; 3 | import Language from './language'; 4 | import Notification from './notification'; 5 | import Section from '../section'; 6 | import Update from './update'; 7 | export default function SettingsGeneral() { 8 | return ( 9 |
10 | 11 | 12 | 13 | {/* */} 14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/general/language.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectGroup, 5 | SelectItem, 6 | SelectTrigger, 7 | SelectValue, 8 | } from '@/components/ui/select'; 9 | import { useTranslation } from 'react-i18next'; 10 | import { useI18n } from '@/i18n'; 11 | import { memo } from 'react'; 12 | import useSettingsStore from '@/store/settings'; 13 | import useSelector from '@/hooks/useSelector'; 14 | import { SettingsKey } from '@/constants'; 15 | import SettingItem from '../setting-item'; 16 | 17 | const languages = [ 18 | { value: 'zh-CN', label: '简体中文' }, 19 | { value: 'en-US', label: 'English(US)' }, 20 | ]; 21 | 22 | export default memo(function SettingsGeneralLanguage() { 23 | const { i18n } = useTranslation(); 24 | const t = useI18n(); 25 | const { language, set } = useSettingsStore(useSelector([SettingsKey.Language, 'set'])); 26 | 27 | const handleChangeLanguage = async (value: string) => { 28 | await set(SettingsKey.Language, value); 29 | i18n.changeLanguage(value); 30 | }; 31 | return ( 32 | 36 | 50 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/general/notification.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/i18n'; 2 | import { memo } from 'react'; 3 | import { invoke } from '@tauri-apps/api/core'; 4 | import { Button } from '@/components/ui/button'; 5 | import { platform } from '@tauri-apps/plugin-os'; 6 | import SettingItem from '../setting-item'; 7 | 8 | const isMacOS = platform() === 'macos'; 9 | 10 | export default memo(function SettingsGeneralNotification() { 11 | const t = useI18n(); 12 | 13 | const handleChangeNotification = async () => { 14 | await invoke('ipc_open_system_preference_notifications'); 15 | }; 16 | 17 | return ( 18 | 22 | {isMacOS && ( 23 | 26 | )} 27 | 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/general/update.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/i18n'; 2 | import { memo, useContext } from 'react'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { SettingsKey } from '@/constants'; 6 | import { Switch } from '@/components/ui/switch'; 7 | import { enable, isEnabled, disable } from '@tauri-apps/plugin-autostart'; 8 | import { useAsyncEffect } from 'ahooks'; 9 | import SettingItem from '../setting-item'; 10 | import { AppContext } from '@/routes'; 11 | 12 | export default memo(function SettingsGeneralUpdate() { 13 | const t = useI18n(); 14 | const { auto_check_update: autoCheckUpdate, set } = useSettingsStore( 15 | useSelector([SettingsKey.AutoCheckUpdate, 'set']), 16 | ); 17 | const { messageApi } = useContext(AppContext); 18 | const handleChangeAutoCheckUpdate = async (value: boolean) => { 19 | try { 20 | if (value) { 21 | await enable(); 22 | } else { 23 | await disable(); 24 | } 25 | set(SettingsKey.Autostart, value); 26 | } catch (error) { 27 | messageApi?.error(t('tips.autostart.error')); 28 | } 29 | }; 30 | 31 | useAsyncEffect(async () => { 32 | const enable = await isEnabled(); 33 | set(SettingsKey.Autostart, enable); 34 | }, []); 35 | 36 | return ( 37 | 41 | 42 | 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import { ReactNode } from 'react'; 3 | interface SettingsHeaderProps { 4 | title: ReactNode; 5 | description?: ReactNode; 6 | className?: string; 7 | children?: ReactNode; 8 | } 9 | export default function SettingsHeader({ 10 | title, 11 | description, 12 | className, 13 | children, 14 | }: SettingsHeaderProps) { 15 | return ( 16 |
17 |
18 |

{title}

19 | {description &&

{description}

} 20 |
21 |
{children}
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router'; 2 | import { Separator } from '@/components/ui/separator'; 3 | import { SidebarNav } from './sidebar-nav'; 4 | import { Settings2, FileArchive, Panda, Info, FolderSync, RefreshCw } from 'lucide-react'; 5 | import { useI18n } from '@/i18n'; 6 | import { Button } from '@/components/ui/button'; 7 | import useSettingsStore from '@/store/settings'; 8 | import useSelector from '@/hooks/useSelector'; 9 | import { sleep } from '@/utils'; 10 | import { showAlertDialog } from '@/components/ui/alert-dialog'; 11 | import Header from './header'; 12 | import { ScrollArea } from '@/components/ui/scroll-area'; 13 | import { AppContext } from '@/routes'; 14 | import { useContext } from 'react'; 15 | 16 | export default function SettingsLayout() { 17 | const t = useI18n(); 18 | const { reset, init } = useSettingsStore(useSelector(['reset', 'init'])); 19 | const { messageApi } = useContext(AppContext); 20 | const sidebarNavItems = [ 21 | { 22 | title: t('settings.general.title'), 23 | href: '/settings/general', 24 | icon: , 25 | }, 26 | { 27 | title: t('settings.compression.title'), 28 | href: '/settings/compression', 29 | icon: , 30 | }, 31 | { 32 | title: t('settings.tinypng.title'), 33 | href: '/settings/tinypng', 34 | icon: , 35 | }, 36 | { 37 | title: t('settings.about.title'), 38 | href: '/settings/about', 39 | icon: , 40 | }, 41 | ]; 42 | 43 | const handleReload = async () => { 44 | await init(true); 45 | messageApi?.success(t('tips.settings_reload_success')); 46 | }; 47 | 48 | const handleReset = () => { 49 | showAlertDialog({ 50 | title: t('settings.reset_all_confirm'), 51 | cancelText: t('cancel'), 52 | okText: t('confirm'), 53 | onConfirm: async () => { 54 | await sleep(1000); 55 | await reset(); 56 | messageApi?.success(t('tips.settings_reset_success')); 57 | }, 58 | }); 59 | }; 60 | 61 | return ( 62 |
63 |
64 | 68 | 72 |
73 | 74 |
75 | 78 | 79 | 80 | 81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/section.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, PropsWithChildren, forwardRef } from 'react'; 2 | import { cn } from '@/lib/utils'; 3 | 4 | export type SettingsSectionProps = PropsWithChildren>; 5 | 6 | const SettingsSection = forwardRef( 7 | ({ children, className, ...props }, ref) => { 8 | return ( 9 |
14 | {children} 15 |
16 | ); 17 | }, 18 | ); 19 | 20 | export default SettingsSection; 21 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/setting-item.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactNode } from 'react'; 2 | import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; 3 | import { cn } from '@/lib/utils'; 4 | 5 | export type SettingItemProps = PropsWithChildren<{ 6 | id?: string; 7 | className?: string; 8 | title: ReactNode; 9 | titleClassName?: string; 10 | description?: ReactNode; 11 | descriptionClassName?: string; 12 | }>; 13 | 14 | function SettingItem({ 15 | id, 16 | title, 17 | description, 18 | children, 19 | className, 20 | titleClassName, 21 | descriptionClassName, 22 | }: SettingItemProps) { 23 | return ( 24 | 28 |
29 | {title} 30 | {description && ( 31 | 32 | {description} 33 | 34 | )} 35 |
36 |
37 |
{children}
38 |
39 |
40 | ); 41 | } 42 | 43 | export default SettingItem; 44 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router'; 2 | import { cn } from '@/lib/utils'; 3 | import { Button } from '@/components/ui/button'; 4 | import Link from '@/components/link'; 5 | interface SidebarNavProps extends React.HTMLAttributes { 6 | items: { 7 | href: string; 8 | title: string; 9 | icon?: React.ReactNode; 10 | }[]; 11 | } 12 | 13 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) { 14 | const location = useLocation(); 15 | const isActive = (item: { href: string }) => location.pathname.startsWith(item.href); 16 | 17 | return ( 18 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/tinypng/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card'; 2 | import Metadata from './metadata'; 3 | import ApiKeys from './api-keys'; 4 | import Section from '../section'; 5 | import { memo, useEffect, useRef } from 'react'; 6 | 7 | export default memo(function SettingsTinypng() { 8 | const elRef = useRef(null); 9 | 10 | useEffect(() => { 11 | const hash = window.location.hash; 12 | if (elRef.current && hash === '#tinypng-api-keys') { 13 | setTimeout(() => { 14 | elRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); 15 | elRef.current.classList.add('breathe-highlight'); 16 | }, 300); 17 | } 18 | }, []); 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 | 26 |
27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/pages/settings/tinypng/metadata.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useI18n } from '@/i18n'; 3 | import useSettingsStore from '@/store/settings'; 4 | import useSelector from '@/hooks/useSelector'; 5 | import { SettingsKey, TinypngMetadata } from '@/constants'; 6 | import SettingItem from '../setting-item'; 7 | import { CheckboxGroup } from '@/components/checkbox-group'; 8 | 9 | function SettingsCompressionMetadata() { 10 | const t = useI18n(); 11 | const { tinypng_preserve_metadata: metadata = [], set } = useSettingsStore( 12 | useSelector([SettingsKey.TinypngPreserveMetadata, 'set']), 13 | ); 14 | 15 | const options = [ 16 | { 17 | value: TinypngMetadata.Copyright, 18 | label: t('settings.tinypng.metadata.copyright'), 19 | }, 20 | { 21 | value: TinypngMetadata.Creator, 22 | label: t('settings.tinypng.metadata.creator'), 23 | }, 24 | { 25 | value: TinypngMetadata.Location, 26 | label: t('settings.tinypng.metadata.location'), 27 | }, 28 | ]; 29 | 30 | const handleValueChange = (value: string[]) => { 31 | set(SettingsKey.TinypngPreserveMetadata, value); 32 | }; 33 | 34 | return ( 35 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default memo(SettingsCompressionMetadata); 45 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/routes.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; 2 | import AppLayout from './components/layouts/app-layout'; 3 | import Compression from './pages/compression'; 4 | import ClassicCompressionGuide from './pages/compression/classic-guide'; 5 | import WatchCompressionGuide from './pages/compression/watch-guide'; 6 | import CompressionClassic from './pages/compression/classic'; 7 | import CompressionWatch from './pages/compression/watch'; 8 | import Settings from './pages/settings'; 9 | import SettingsGeneral from './pages/settings/general'; 10 | import SettingsCompression from './pages/settings/compression'; 11 | import SettingsTinypng from './pages/settings/tinypng'; 12 | import SettingsAbout from './pages/settings/about'; 13 | import ImageCompare from './pages/image-compare'; 14 | import Update from './pages/update'; 15 | import { Toaster } from 'sonner'; 16 | import { TooltipProvider } from '@/components/ui/tooltip'; 17 | import { useTheme } from '@/components/theme-provider'; 18 | import MessageDemo from './pages/message-demo'; 19 | import { ThemeProvider } from './components/theme-provider'; 20 | import { message, notification } from 'antd'; 21 | import { createContext } from 'react'; 22 | 23 | export const AppContext = createContext<{ 24 | messageApi: ReturnType[0]; 25 | notificationApi: ReturnType[0]; 26 | }>({ 27 | messageApi: null, 28 | notificationApi: null, 29 | }); 30 | 31 | export default function AppRoutes() { 32 | const { theme } = useTheme(); 33 | const [messageApi, messageContextHolder] = message.useMessage(); 34 | const [notificationApi, notificationContextHolder] = notification.useNotification(); 35 | return ( 36 | 37 | 38 | {messageContextHolder} 39 | {notificationContextHolder} 40 | 41 | 51 | 52 | 53 | }> 54 | } /> 55 | {/* } /> */} 56 | } /> 57 | }> 58 | } /> 59 | 60 | } /> 61 | } /> 62 | } /> 63 | 64 | 65 | } /> 66 | } /> 67 | } /> 68 | 69 | 70 | }> 71 | } /> 72 | } /> 73 | } /> 74 | } /> 75 | } /> 76 | 77 | } /> 78 | } /> 79 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/store/compression.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import EventEmitter from 'eventemitter3'; 3 | import useAppStore from './app'; 4 | 5 | interface CompressionState { 6 | // 是否正在工作区 7 | working: boolean; 8 | // 是否正在压缩 9 | inCompressing: boolean; 10 | // 是否正在监控文件夹 11 | watchingFolder: string; 12 | eventEmitter: EventEmitter; 13 | // 文件列表 14 | files: FileInfo[]; 15 | fileMap: Map; 16 | // 选中的文件 17 | selectedFiles: string[]; 18 | } 19 | 20 | interface CompressionAction { 21 | setWorking: (value: boolean) => void; 22 | setInCompressing: (inCompressing: boolean) => void; 23 | setWatchingFolder: (path: string) => void; 24 | setFiles: (files: FileInfo[]) => void; 25 | removeFile: (path: string) => void; 26 | reset: () => void; 27 | } 28 | 29 | const useCompressionStore = create((set, get) => ({ 30 | working: false, 31 | eventEmitter: new EventEmitter(), 32 | watchingFolder: '', 33 | files: [], 34 | fileMap: new Map(), 35 | selectedFiles: [], 36 | inCompressing: false, 37 | setWorking: (value: boolean) => { 38 | set({ working: value }); 39 | }, 40 | setInCompressing: (inCompressing: boolean) => { 41 | set({ inCompressing }); 42 | }, 43 | setWatchingFolder: (path) => { 44 | set({ watchingFolder: path }); 45 | }, 46 | setFiles: (files: FileInfo[]) => { 47 | set({ 48 | files, 49 | fileMap: new Map(files.map((file) => [file.path, file])), 50 | selectedFiles: files.map((file) => file.path), 51 | }); 52 | }, 53 | removeFile: (path: string) => { 54 | const targetIndex = get().files.findIndex((file) => file.path === path); 55 | if (targetIndex !== -1) { 56 | get().files.splice(targetIndex, 1); 57 | get().fileMap.delete(path); 58 | const selectedFiles = get().selectedFiles.filter((file) => file !== path); 59 | set({ 60 | files: [...get().files], 61 | fileMap: new Map(get().fileMap), 62 | selectedFiles, 63 | }); 64 | } 65 | }, 66 | reset: () => { 67 | useAppStore.getState().clearImageCache(); 68 | set({ 69 | working: false, 70 | inCompressing: false, 71 | watchingFolder: '', 72 | files: [], 73 | fileMap: new Map(), 74 | selectedFiles: [], 75 | }); 76 | }, 77 | })); 78 | 79 | export default useCompressionStore; 80 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/store/withStorageDOMEvents.ts: -------------------------------------------------------------------------------- 1 | import { Mutate, StoreApi } from 'zustand'; 2 | type StoreWithPersist = Mutate, [['zustand/persist', unknown]]>; 3 | 4 | type StorageEventCallback = (e: StorageEvent) => void | Promise; 5 | const map = new Map(); 6 | 7 | const storageEventCallback = (e: StorageEvent) => { 8 | for (const [store, cb] of map.entries()) { 9 | if (e.key === store.persist.getOptions().name) { 10 | cb(e); 11 | } 12 | } 13 | }; 14 | window.addEventListener('storage', storageEventCallback); 15 | 16 | export const withStorageDOMEvents = (store: StoreWithPersist, cb: StorageEventCallback) => { 17 | map.set(store, cb); 18 | }; 19 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { ICompressor } from '../utils/compressor'; 2 | import type { CompressionOutputMode } from '../constants'; 3 | 4 | declare global { 5 | interface ConvertResult { 6 | success: boolean; 7 | output_path: string; 8 | format: string; 9 | error_msg?: string; 10 | info: { 11 | format: string; 12 | width: number; 13 | height: number; 14 | channels: number; 15 | premultiplied: boolean; 16 | size: number; 17 | }; 18 | } 19 | 20 | interface FileInfo { 21 | id: string; 22 | name: string; 23 | path: string; 24 | parentDir: string; 25 | assetPath: string; 26 | bytesSize: number; 27 | formattedBytesSize: string; 28 | diskSize: number; 29 | formattedDiskSize: string; 30 | ext: string; 31 | mimeType: string; 32 | compressedBytesSize: number; 33 | formattedCompressedBytesSize: string; 34 | compressedDiskSize: number; 35 | formattedCompressedDiskSize: string; 36 | compressRate: string; 37 | outputPath: string; 38 | status: ICompressor.Status; 39 | originalTempPath: string; 40 | errorMessage?: string; 41 | saveType?: CompressionOutputMode; 42 | convertResults?: ConvertResult[]; 43 | } 44 | } 45 | 46 | export {}; 47 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/NativeCompressor.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '@tauri-apps/api/event'; 2 | import { invoke } from '@tauri-apps/api/core'; 3 | import { isFunction } from 'radash'; 4 | import EventEmitter from 'eventemitter3'; 5 | 6 | export namespace INativeCompressor { 7 | export enum EventType { 8 | CompressionProgress = 'compression-progress', 9 | CompressionCompleted = 'compression-completed', 10 | } 11 | 12 | export enum CompressionStatus { 13 | Success = 'Success', 14 | Failed = 'Failed', 15 | } 16 | 17 | export interface CompressionResult { 18 | input_path: string; 19 | status: CompressionStatus; 20 | output_path: string; 21 | output_path_converted: string; 22 | compressed_bytes_size: number; 23 | compressed_disk_size: number; 24 | cost_time: number; 25 | compress_rate: number; 26 | error_message?: string; 27 | original_temp_path: string; 28 | } 29 | 30 | export type CompressionProgressCallback = (result: CompressionResult) => void; 31 | export type CompressionCompletedCallback = (results: CompressionResult[]) => void; 32 | } 33 | 34 | export class NativeCompressor extends EventEmitter { 35 | private static instance: NativeCompressor; 36 | private progressUnlisten?: () => void; 37 | private completedUnlisten?: () => void; 38 | 39 | constructor() { 40 | super(); 41 | this.setupEventListeners(); 42 | } 43 | 44 | public static getInstance(): NativeCompressor { 45 | if (!NativeCompressor.instance) { 46 | NativeCompressor.instance = new NativeCompressor(); 47 | } 48 | return NativeCompressor.instance; 49 | } 50 | 51 | private async setupEventListeners(): Promise { 52 | this.progressUnlisten = await listen( 53 | 'compression-progress', 54 | (event) => { 55 | this.emit('compression-progress', event.payload); 56 | }, 57 | ); 58 | 59 | this.completedUnlisten = await listen( 60 | 'compression-completed', 61 | (event) => { 62 | this.emit('compression-completed', event.payload); 63 | }, 64 | ); 65 | } 66 | 67 | public async compress( 68 | filePaths: string[], 69 | onProgress?: INativeCompressor.CompressionProgressCallback, 70 | onCompleted?: INativeCompressor.CompressionCompletedCallback, 71 | ): Promise { 72 | if (isFunction(onProgress)) { 73 | this.on(INativeCompressor.EventType.CompressionProgress, onProgress); 74 | } 75 | 76 | if (isFunction(onCompleted)) { 77 | this.on(INativeCompressor.EventType.CompressionCompleted, onCompleted); 78 | } 79 | 80 | try { 81 | await invoke('ipc_compress_images', { 82 | paths: filePaths, 83 | }); 84 | } catch (error) { 85 | console.error('Failed to compress images:', error); 86 | throw error; 87 | } finally { 88 | if (isFunction(onProgress)) { 89 | this.off(INativeCompressor.EventType.CompressionProgress, onProgress); 90 | } 91 | 92 | if (isFunction(onCompleted)) { 93 | this.off(INativeCompressor.EventType.CompressionCompleted, onCompleted); 94 | } 95 | } 96 | } 97 | 98 | public dispose(): void { 99 | this.progressUnlisten?.(); 100 | this.completedUnlisten?.(); 101 | this.removeAllListeners(); 102 | } 103 | } 104 | 105 | // 导出单例实例 106 | export const nativeCompressor = NativeCompressor.getInstance(); 107 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/fs-watch.ts: -------------------------------------------------------------------------------- 1 | import { watch, UnwatchFn } from "@tauri-apps/plugin-fs"; 2 | import { exists } from "@tauri-apps/plugin-fs"; 3 | import { get } from "radash"; 4 | 5 | export interface WatchCallbacks { 6 | onCreate?: (type: "file" | "folder", paths: string[]) => void; 7 | onRemove?: (type: "file" | "folder", paths: string[]) => void; 8 | onRename?: (from: string, to: string) => void; 9 | onMove?: (to: string) => void; 10 | // onModify?: (paths: string[]) => void; 11 | } 12 | 13 | export interface WatchEvent { 14 | type: { 15 | create?: { kind: "file" | "folder" }; 16 | remove?: { kind: "file" | "folder" }; 17 | modify?: { 18 | kind: string; 19 | mode: "any" | "both" | "from" | "to" | "content"; 20 | }; 21 | other?: any; 22 | }; 23 | paths: string[]; 24 | } 25 | 26 | export interface WatchEventStrategy { 27 | handle(event: WatchEvent, callbacks: Partial): void; 28 | } 29 | 30 | export class CreateEventStrategy implements WatchEventStrategy { 31 | handle(event: WatchEvent, callbacks: Partial): void { 32 | const { type, paths } = event; 33 | if (get(type, "create")) { 34 | callbacks.onCreate?.(get(type, "create.kind"), paths); 35 | } 36 | } 37 | } 38 | 39 | export class RemoveEventStrategy implements WatchEventStrategy { 40 | handle(event: WatchEvent, callbacks: Partial): void { 41 | const { type, paths } = event; 42 | if (get(type, "remove")) { 43 | callbacks.onRemove?.(get(type, "remove.kind"), paths); 44 | } 45 | } 46 | } 47 | 48 | export class RenameEventStrategy implements WatchEventStrategy { 49 | handle(event: WatchEvent, callbacks: Partial): void { 50 | const { type, paths } = event; 51 | if ( 52 | get(type, "modify.kind") === "rename" && 53 | get(type, "modify.mode") === "both" 54 | ) { 55 | callbacks.onRename?.(paths[0], paths[1]); 56 | } 57 | } 58 | } 59 | 60 | export class MoveEventStrategy implements WatchEventStrategy { 61 | handle(event: WatchEvent, callbacks: Partial): void { 62 | const { type, paths } = event; 63 | if ( 64 | get(type, "modify.kind") === "rename" && 65 | get(type, "modify.mode") === "any" 66 | ) { 67 | callbacks.onMove?.(paths[0]); 68 | } 69 | } 70 | } 71 | // export class ModifyContentEventStrategy implements WatchEventStrategy { 72 | // handle(event: WatchEvent, callbacks: Partial): void { 73 | // const { type, paths } = event; 74 | // if ( 75 | // get(type, "modify.kind") === "content" || 76 | // (get(type, "modify.kind") !== "rename" && get(type, "modify")) 77 | // ) { 78 | // callbacks.onModify?.(paths); 79 | // } 80 | // } 81 | // } 82 | 83 | export class WatchEventContext { 84 | private strategies: WatchEventStrategy[] = []; 85 | 86 | constructor() { 87 | this.strategies.push(new CreateEventStrategy()); 88 | this.strategies.push(new RemoveEventStrategy()); 89 | this.strategies.push(new RenameEventStrategy()); 90 | this.strategies.push(new MoveEventStrategy()); 91 | // this.strategies.push(new ModifyContentEventStrategy()); 92 | } 93 | 94 | executeStrategies( 95 | event: WatchEvent, 96 | callbacks: Partial 97 | ): void { 98 | for (const strategy of this.strategies) { 99 | strategy.handle(event, callbacks); 100 | } 101 | } 102 | 103 | addStrategy(strategy: WatchEventStrategy): void { 104 | this.strategies.push(strategy); 105 | } 106 | } 107 | 108 | export const watchFolder = async ( 109 | path: string, 110 | callbacks: Partial 111 | ): Promise => { 112 | const context = new WatchEventContext(); 113 | 114 | return watch( 115 | path, 116 | async (event) => { 117 | // console.log("event", event); 118 | context.executeStrategies(event as WatchEvent, callbacks); 119 | }, 120 | { delayMs: 1000, recursive: true } 121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import { convertFileSrc, invoke } from '@tauri-apps/api/core'; 2 | import { ICompressor } from './compressor'; 3 | import { isValidArray } from '.'; 4 | import { CompressionOutputMode } from '@/constants'; 5 | import { copyFile, exists, remove } from '@tauri-apps/plugin-fs'; 6 | 7 | const mags = ' KMGTPEZY'; 8 | export function humanSize(bytes: number, precision: number = 1) { 9 | const magnitude = Math.min((Math.log(bytes) / Math.log(1024)) | 0, mags.length - 1); 10 | const result = bytes / Math.pow(1024, magnitude); 11 | const suffix = mags[magnitude].trim() + 'B'; 12 | return result.toFixed(precision) + suffix; 13 | } 14 | 15 | export interface ParsePathsItem { 16 | id: string; 17 | name: string; 18 | path: string; 19 | base_dir: string; 20 | asset_path: string; 21 | bytes_size: number; 22 | formatted_bytes_size: string; 23 | disk_size: number; 24 | formatted_disk_size: string; 25 | ext: string; 26 | mime_type: string; 27 | } 28 | 29 | export async function parsePaths(paths: string[], validExts: string[]) { 30 | const candidates = await invoke('ipc_parse_paths', { 31 | paths, 32 | validExts, 33 | }); 34 | if (isValidArray(candidates)) { 35 | return candidates.map((item) => ({ 36 | id: item.id, 37 | path: item.path, 38 | assetPath: convertFileSrc(item.path), 39 | name: item.name, 40 | parentDir: item.base_dir, 41 | bytesSize: item.bytes_size, 42 | formattedBytesSize: humanSize(item.bytes_size), 43 | diskSize: item.disk_size, 44 | formattedDiskSize: humanSize(item.disk_size), 45 | mimeType: item.mime_type, 46 | ext: item.ext, 47 | compressedBytesSize: 0, 48 | formattedCompressedBytesSize: '', 49 | compressedDiskSize: 0, 50 | formattedCompressedDiskSize: '', 51 | compressRate: '', 52 | outputPath: '', 53 | status: ICompressor.Status.Pending, 54 | originalTempPath: '', 55 | })); 56 | } 57 | return []; 58 | } 59 | 60 | export async function countValidFiles(paths: string[], validExts: string[]) { 61 | const count = await invoke('ipc_count_valid_files', { 62 | paths, 63 | validExts, 64 | }); 65 | return count; 66 | } 67 | 68 | export function getFilename(path: string): string { 69 | if (typeof path !== 'string' || !path.trim()) { 70 | return ''; 71 | } 72 | 73 | const cleanPath = path.trim().replace(/[/\\]+$/, ''); 74 | 75 | if (!cleanPath) { 76 | return ''; 77 | } 78 | 79 | const pathParts = cleanPath.split(/[/\\]+/).filter((part) => part.length > 0); 80 | 81 | if (pathParts.length === 0) { 82 | return ''; 83 | } 84 | 85 | const filename = pathParts[pathParts.length - 1]; 86 | 87 | if (filename.startsWith('.') && filename.indexOf('.', 1) === -1) { 88 | return filename; 89 | } 90 | 91 | const dotIndex = filename.lastIndexOf('.'); 92 | if (dotIndex === -1 || dotIndex === 0) { 93 | return filename; 94 | } 95 | 96 | return filename; 97 | } 98 | 99 | export async function undoSave(file: FileInfo) { 100 | if ( 101 | file.status === ICompressor.Status.Completed && 102 | file.outputPath && 103 | file.originalTempPath && 104 | file.saveType 105 | ) { 106 | const { path, outputPath, originalTempPath, saveType } = file; 107 | if (!(await exists(originalTempPath))) { 108 | return { 109 | success: false, 110 | message: 'undo.original_file_not_exists', 111 | }; 112 | } 113 | if (saveType === CompressionOutputMode.Overwrite) { 114 | copyFile(originalTempPath, path); 115 | } else { 116 | if (!(await exists(outputPath))) { 117 | return { 118 | success: false, 119 | message: 'undo.output_file_not_exists', 120 | }; 121 | } 122 | if (file.path === file.outputPath) { 123 | copyFile(originalTempPath, path); 124 | } else { 125 | remove(outputPath); 126 | } 127 | } 128 | if (isValidArray(file.convertResults)) { 129 | await Promise.all( 130 | file.convertResults.map(async (item) => { 131 | if (item.success && (await exists(item.output_path))) { 132 | return remove(item.output_path); 133 | } 134 | return Promise.resolve(); 135 | }), 136 | ); 137 | } 138 | return { 139 | success: true, 140 | message: 'undo.success', 141 | }; 142 | } 143 | return { 144 | success: false, 145 | message: 'undo.no_allow_undo', 146 | }; 147 | } 148 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { platform } from '@tauri-apps/plugin-os'; 2 | export const validTinifyExts = [ 3 | 'png', 4 | 'jpg', 5 | 'jpeg', 6 | 'jpeg', 7 | 'webp', 8 | 'avif', 9 | 'gif', 10 | 'svg', 11 | 'tiff', 12 | 'tif', 13 | ]; 14 | 15 | export function isAvailableTinifyExt(ext: string) { 16 | return validTinifyExts.includes(ext); 17 | } 18 | 19 | export function isAvailableImageExt(ext: string) { 20 | return isAvailableTinifyExt(ext); 21 | } 22 | 23 | export function isValidArray(arr: unknown) { 24 | return Array.isArray(arr) && arr.length > 0; 25 | } 26 | 27 | export function sleep(ms: number) { 28 | return new Promise((resolve) => setTimeout(resolve, ms)); 29 | } 30 | 31 | export function preventDefault(event) { 32 | event.preventDefault(); 33 | } 34 | 35 | export function stopPropagation(event) { 36 | event.stopPropagation(); 37 | } 38 | 39 | export const isDev = import.meta.env.DEV; 40 | 41 | export const isProd = import.meta.env.PROD; 42 | 43 | export const isMac = platform() === 'macos'; 44 | export const isWindows = platform() === 'windows'; 45 | export const isLinux = platform() === 'linux'; 46 | 47 | export const getUserLocale = (lang: string): string | undefined => { 48 | const languages = 49 | navigator.languages && navigator.languages.length > 0 50 | ? navigator.languages 51 | : [navigator.language]; 52 | 53 | const filteredLocales = languages.filter((locale) => locale.startsWith(lang)); 54 | return filteredLocales.length > 0 ? filteredLocales[0] : undefined; 55 | }; 56 | 57 | // Note that iPad may have a user agent string like a desktop browser 58 | // when possible please use appService.isIOSApp || getOSPlatform() === 'ios' 59 | // to check if the app is running on iOS 60 | export const getOSPlatform = () => { 61 | const userAgent = navigator.userAgent.toLowerCase(); 62 | 63 | if (/iphone|ipad|ipod/.test(userAgent)) return 'ios'; 64 | if (userAgent.includes('android')) return 'android'; 65 | if (userAgent.includes('macintosh') || userAgent.includes('mac os x')) return 'macos'; 66 | if (userAgent.includes('windows nt')) return 'windows'; 67 | if (userAgent.includes('linux')) return 'linux'; 68 | 69 | return ''; 70 | }; 71 | 72 | export async function uint8ArrayToRGBA( 73 | uint8Array: Uint8Array, 74 | mimeType: string, 75 | ): Promise<{ 76 | rgba: Uint8ClampedArray; 77 | width: number; 78 | height: number; 79 | }> { 80 | const blob = new Blob([uint8Array.buffer], { type: mimeType }); 81 | const imageBitmap = await createImageBitmap(blob); 82 | 83 | const canvas = document.createElement('canvas'); 84 | canvas.width = imageBitmap.width; 85 | canvas.height = imageBitmap.height; 86 | 87 | const ctx = canvas.getContext('2d')!; 88 | ctx.drawImage(imageBitmap, 0, 0); 89 | 90 | const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); 91 | 92 | return { 93 | rgba: data, 94 | width: canvas.width, 95 | height: canvas.height, 96 | }; 97 | } 98 | 99 | export function correctFloat(value: number, precision = 12) { 100 | return parseFloat(value.toPrecision(precision)); 101 | } 102 | 103 | export function calProgress(current: number, total: number) { 104 | return correctFloat(Number((current / total).toFixed(2)) * 100); 105 | } 106 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/launch.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | LAUNCH_PAYLOAD?: LaunchPayload; 4 | } 5 | } 6 | 7 | interface CliArgument { 8 | value: string; 9 | occurrences: number; 10 | } 11 | 12 | interface LaunchPayload { 13 | mode: string; 14 | paths: string[]; 15 | file: FileInfo; 16 | } 17 | 18 | const parseLaunchPayload = () => { 19 | return window.LAUNCH_PAYLOAD; 20 | }; 21 | 22 | // const parseCLIOpenWithFiles = async () => { 23 | // const { getMatches } = await import('@tauri-apps/plugin-cli'); 24 | // const matches = await getMatches(); 25 | // const args = matches?.args; 26 | // const files: string[] = []; 27 | // if (args) { 28 | // for (const name of ['file1', 'file2', 'file3', 'file4']) { 29 | // const arg = args[name] as CliArgument; 30 | // if (arg && arg.occurrences > 0) { 31 | // files.push(arg.value); 32 | // } 33 | // } 34 | // } 35 | 36 | // return files; 37 | // }; 38 | 39 | export const parseOpenWithFiles = () => { 40 | let payload = parseLaunchPayload(); 41 | // if (!files && hasCli()) { 42 | // files = await parseCLIOpenWithFiles(); 43 | // } 44 | return payload; 45 | }; 46 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { isProd } from '.'; 2 | import { 3 | info as tauriInfo, 4 | debug as tauriDebug, 5 | trace as tauriTrace, 6 | warn as tauriWarn, 7 | error as tauriError, 8 | } from '@tauri-apps/plugin-log'; 9 | 10 | interface Logger { 11 | log: (message: string, ...args: unknown[]) => void; 12 | info: (message: string, ...args: unknown[]) => void; 13 | debug: (message: string, ...args: unknown[]) => void; 14 | trace: (message: string, ...args: unknown[]) => void; 15 | warn: (message: string, ...args: unknown[]) => void; 16 | error: (message: string, ...args: unknown[]) => void; 17 | } 18 | 19 | const devLogger: Logger = { 20 | log: console.log, 21 | info: console.info, 22 | debug: console.debug, 23 | trace: console.trace, 24 | warn: console.warn, 25 | error: console.error, 26 | }; 27 | 28 | const prodLogger: Logger = { 29 | log: (message, ...args) => { 30 | tauriInfo(formatMessage(message, args)); 31 | console.log(message, ...args); 32 | }, 33 | info: (message, ...args) => { 34 | tauriInfo(formatMessage(message, args)); 35 | console.info(message, ...args); 36 | }, 37 | debug: (message, ...args) => { 38 | tauriDebug(formatMessage(message, args)); 39 | console.debug(message, ...args); 40 | }, 41 | trace: (message, ...args) => { 42 | tauriTrace(formatMessage(message, args)); 43 | console.trace(message, ...args); 44 | }, 45 | warn: (message, ...args) => { 46 | tauriWarn(formatMessage(message, args)); 47 | console.warn(message, ...args); 48 | }, 49 | error: (message, ...args) => { 50 | tauriError(formatMessage(message, args)); 51 | console.error(message, ...args); 52 | }, 53 | }; 54 | 55 | function formatMessage(message: string, args: unknown[]): string { 56 | if (args.length === 0) { 57 | return message; 58 | } 59 | try { 60 | return `${message} ${args.map((arg) => (typeof arg === 'object' && arg !== null ? JSON.stringify(arg) : String(arg))).join(' ')}`; 61 | } catch (e) { 62 | // Fallback for circular structures or other stringify errors 63 | return `${message} ${args.map((arg) => String(arg)).join(' ')}`; 64 | } 65 | } 66 | 67 | export const logger: Logger = isProd ? prodLogger : devLogger; 68 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | import { sendNotification } from '@tauri-apps/plugin-notification'; 2 | 3 | export const sendTextNotification = (title: string, body: string) => { 4 | sendNotification({ title, body, autoCancel: true }); 5 | }; 6 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/scheduler.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'eventemitter3'; 2 | export namespace IScheduler { 3 | 4 | export type Task = ()=>Promise; 5 | 6 | export type TaskResult = any; 7 | 8 | export enum TaskStatus { 9 | Pending = 'pending', 10 | Processing = 'processing', 11 | Completed = 'completed', 12 | Failed = 'failed', 13 | Saving = 'saving', 14 | Done = 'done', 15 | } 16 | } 17 | 18 | export interface SchedulerOptions{ 19 | concurrency:number; 20 | } 21 | 22 | export default class Scheduler extends EventEmitter{ 23 | static Events = { 24 | Fulfilled: 'fulfilled', 25 | Rejected: 'rejected', 26 | } 27 | 28 | private running = false; 29 | private concurrency:number 30 | private tasks:Array<()=>Promise> = [] 31 | private results:Array = [] 32 | 33 | constructor(options:SchedulerOptions){ 34 | super(); 35 | this.concurrency = options.concurrency || 6; 36 | } 37 | 38 | public addTasks(...tasks:IScheduler.Task[]){ 39 | if(this.running) return; 40 | this.tasks.push(...tasks); 41 | return this; 42 | } 43 | 44 | public setTasks(tasks:IScheduler.Task[]){ 45 | if(this.running) return; 46 | this.tasks = tasks; 47 | return this; 48 | } 49 | 50 | private execute() { 51 | let i = 0; 52 | const ret:Array> = []; 53 | const executing:Array> = []; 54 | const enqueue = (): Promise => { 55 | if (i === this.tasks.length) { 56 | return Promise.resolve(); 57 | } 58 | const task = this.tasks[i++]; 59 | const p = Promise.resolve() 60 | .then(() => task()) 61 | .then((res) => { 62 | this.emit(Scheduler.Events.Fulfilled,res); 63 | return res; 64 | }) 65 | .catch((res) => { 66 | this.emit(Scheduler.Events.Rejected,res); 67 | }); 68 | ret.push(p); 69 | 70 | let r = Promise.resolve(); 71 | if (this.concurrency <= this.tasks.length) { 72 | const e:Promise = p.then(() => { 73 | return executing.splice(executing.indexOf(e), 1); 74 | }); 75 | executing.push(e); 76 | if (executing.length >= this.concurrency) { 77 | r = Promise.race(executing); 78 | } 79 | } 80 | return r.then(() => enqueue()); 81 | }; 82 | return enqueue().then(() => Promise.all(ret)); 83 | } 84 | 85 | 86 | async run() { 87 | if(this.running) return; 88 | this.running = true; 89 | this.results = await this.execute(); 90 | this.running = false; 91 | return this.results; 92 | } 93 | } -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/tinify.ts: -------------------------------------------------------------------------------- 1 | import { upload } from '@tauri-apps/plugin-upload'; 2 | import { isValidArray } from '@/utils'; 3 | import { draw } from 'radash'; 4 | import { fetch } from '@tauri-apps/plugin-http'; 5 | 6 | export namespace ITinify { 7 | export interface ApiCompressResult { 8 | input: { 9 | size: number; 10 | type: string; 11 | }; 12 | output: { 13 | width: number; 14 | height: number; 15 | ratio: number; 16 | size: number; 17 | type: string; 18 | url: string; 19 | }; 20 | } 21 | export interface CompressResult extends ApiCompressResult { 22 | id: string; 23 | } 24 | } 25 | 26 | export class Tinify { 27 | private static API_ENDPOINT = 'https://api.tinify.com'; 28 | 29 | apiKeys: string[] = []; 30 | apiKey64s: Map = new Map(); 31 | 32 | constructor(apiKeys: string[]) { 33 | this.apiKeys = apiKeys.filter(Boolean); 34 | this.apiKey64s = new Map(this.apiKeys.map((apiKey) => [apiKey, btoa(`api:${apiKey}`)])); 35 | } 36 | 37 | public async compress(filePtah: string, mime: string): Promise { 38 | return new Promise(async (resolve, reject) => { 39 | if (!isValidArray(this.apiKeys)) { 40 | return reject(new TypeError('TinyPNG API Keys is empty')); 41 | } 42 | try { 43 | const apiKey = draw(this.apiKeys); 44 | const headers = new Map(); 45 | headers.set('Content-Type', mime); 46 | headers.set('Authorization', `Basic ${this.apiKey64s.get(apiKey)}`); 47 | const result = await upload(`${Tinify.API_ENDPOINT}/shrink`, filePtah, undefined, headers); 48 | const payload = JSON.parse(result) as ITinify.ApiCompressResult; 49 | resolve({ 50 | id: filePtah, 51 | input: payload.input, 52 | output: payload.output, 53 | }); 54 | } catch (error) { 55 | reject(error); 56 | } 57 | }); 58 | } 59 | 60 | static async validate(apiKey: string): Promise<{ 61 | ok: boolean; 62 | compressionCount?: string; 63 | }> { 64 | try { 65 | const url = `${Tinify.API_ENDPOINT}/shrink`; 66 | const headers = new Headers({ 67 | Authorization: `Basic ${btoa(`api:${apiKey}`)}`, 68 | 'Content-Type': 'application/json', 69 | }); 70 | const result = await fetch(url, { 71 | method: 'POST', 72 | headers, 73 | }); 74 | if (result.headers.has('compression-count')) { 75 | return { 76 | ok: true, 77 | compressionCount: result.headers.get('compression-count'), 78 | }; 79 | } 80 | return { 81 | ok: false, 82 | }; 83 | } catch (error) { 84 | return { 85 | ok: false, 86 | }; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/tray.ts: -------------------------------------------------------------------------------- 1 | import { Menu } from '@tauri-apps/api/menu'; 2 | import { TrayIcon, TrayIconOptions } from '@tauri-apps/api/tray'; 3 | import { defaultWindowIcon } from '@tauri-apps/api/app'; 4 | import { t } from '../i18n'; 5 | import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; 6 | import { getCurrentWindow } from '@tauri-apps/api/window'; 7 | import { isProd } from '.'; 8 | import { createWebviewWindow } from './window'; 9 | import checkForUpdate from './updater'; 10 | import { message } from '@tauri-apps/plugin-dialog'; 11 | 12 | declare global { 13 | interface Window { 14 | __TRAY_INSTANCE?: TrayIcon; 15 | } 16 | } 17 | 18 | export async function createTrayMenu() { 19 | if (getCurrentWebviewWindow().label !== 'main') return; 20 | const menu = await Menu.new({ 21 | items: [ 22 | { 23 | id: 'open', 24 | text: t('tray.open'), 25 | action: async () => { 26 | await getCurrentWindow().show(); 27 | await getCurrentWindow().setFocus(); 28 | }, 29 | accelerator: 'CmdOrCtrl+O', 30 | }, 31 | { 32 | id: 'settings', 33 | text: t('tray.settings'), 34 | action: () => { 35 | createWebviewWindow('settings', { 36 | url: '/settings', 37 | title: t('nav.settings'), 38 | width: 796, 39 | height: 528, 40 | center: true, 41 | resizable: true, 42 | titleBarStyle: 'overlay', 43 | hiddenTitle: true, 44 | dragDropEnabled: true, 45 | minimizable: true, 46 | maximizable: true, 47 | }); 48 | }, 49 | accelerator: 'CmdOrCtrl+,', 50 | }, 51 | { 52 | id: 'check_update', 53 | text: t('tray.check_update'), 54 | action: async () => { 55 | const updater = await checkForUpdate(); 56 | if (!updater) { 57 | message(t('settings.about.version.no_update_available'), { 58 | title: t('tray.check_update'), 59 | }); 60 | } 61 | }, 62 | accelerator: 'CmdOrCtrl+U', 63 | }, 64 | { 65 | id: 'quit', 66 | text: t('tray.quit'), 67 | action: () => { 68 | getCurrentWindow().destroy(); 69 | }, 70 | accelerator: 'CmdOrCtrl+Q', 71 | }, 72 | ], 73 | }); 74 | return menu; 75 | } 76 | 77 | export async function initTray() { 78 | if (getCurrentWebviewWindow().label !== 'main' || window.__TRAY_INSTANCE) return; 79 | 80 | const menu = await createTrayMenu(); 81 | const icon = await defaultWindowIcon(); 82 | const options: TrayIconOptions = { 83 | tooltip: 'PicSharp', 84 | icon, 85 | iconAsTemplate: true, 86 | menu, 87 | menuOnLeftClick: false, 88 | action: async (event) => { 89 | switch (event.type) { 90 | case 'Click': 91 | if (event.button === 'Right') return; 92 | await getCurrentWindow().show(); 93 | await getCurrentWindow().setFocus(); 94 | break; 95 | } 96 | }, 97 | }; 98 | 99 | const tray = await TrayIcon.new(options); 100 | window.__TRAY_INSTANCE = tray; 101 | } 102 | 103 | if (isProd) { 104 | initTray(); 105 | } 106 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/updater.ts: -------------------------------------------------------------------------------- 1 | import { check } from '@tauri-apps/plugin-updater'; 2 | import { createWebviewWindow } from './window'; 3 | import { t } from '../i18n'; 4 | 5 | export const UPDATE_WINDOW_LABEL = 'update-detail'; 6 | 7 | export default async function checkForUpdate() { 8 | const updater = await check(); 9 | if (updater) { 10 | console.log(`found update ${updater.version} from ${updater.date} with notes ${updater.body}`); 11 | createWebviewWindow(UPDATE_WINDOW_LABEL, { 12 | url: `/update?version=${updater.version}&releaseContent=${encodeURIComponent(updater.body)}`, 13 | title: t('nav.update'), 14 | width: 500, 15 | height: 490, 16 | center: true, 17 | resizable: false, 18 | titleBarStyle: 'overlay', 19 | hiddenTitle: true, 20 | dragDropEnabled: true, 21 | minimizable: false, 22 | maximizable: false, 23 | }); 24 | return updater; 25 | } else { 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/utils/window.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import { WebviewWindow, getAllWebviewWindows } from '@tauri-apps/api/webviewWindow'; 3 | 4 | export function calImageWindowSize(imgWidth: number, imgHeight: number): [number, number] { 5 | const maxWidth = 1000.0; 6 | const maxHeight = 750.0; 7 | const minWidth = 400.0; 8 | const minHeight = 400.0; 9 | 10 | const scaleWidth = maxWidth / imgWidth; 11 | const scaleHeight = maxHeight / imgHeight; 12 | const scale = Math.min(Math.min(scaleWidth, scaleHeight), 1.0); 13 | 14 | let width = Math.max(imgWidth * scale, minWidth); 15 | let height = Math.max(imgHeight * scale, minHeight) + 60; 16 | 17 | return [width, height]; 18 | } 19 | 20 | export interface WindowConfig { 21 | label?: string; 22 | title?: string; 23 | width?: number; 24 | height?: number; 25 | resizable?: boolean; 26 | hiddenTitle?: boolean; 27 | minWidth?: number; 28 | minHeight?: number; 29 | maximizable?: boolean; 30 | minimizable?: boolean; 31 | } 32 | 33 | export async function spawnWindow( 34 | payload: Record = {}, 35 | windowConfig: WindowConfig = {}, 36 | ): Promise { 37 | return invoke('ipc_spawn_window', { 38 | launchPayload: JSON.stringify(payload), 39 | windowConfig, 40 | }); 41 | } 42 | 43 | export async function createWebviewWindow( 44 | label: string, 45 | options: ConstructorParameters[1], 46 | ) { 47 | const windows = await getAllWebviewWindows(); 48 | const target = windows.find((w) => w.label === label); 49 | if (target) { 50 | target.show(); 51 | return target; 52 | } else { 53 | return new WebviewWindow(label, options); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/picsharp-app/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/picsharp-app/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { fontFamily } from 'tailwindcss/defaultTheme'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | darkMode: ['class'], 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | theme: { 8 | extend: { 9 | fontFamily: { 10 | mono: ['IBM Plex Mono', ...fontFamily.mono], 11 | }, 12 | borderRadius: { 13 | lg: 'var(--radius)', 14 | md: 'calc(var(--radius) - 2px)', 15 | sm: 'calc(var(--radius) - 4px)', 16 | }, 17 | colors: { 18 | border: 'hsl(var(--border))', 19 | background: 'hsl(var(--background))', 20 | foreground: 'hsl(var(--foreground))', 21 | }, 22 | }, 23 | }, 24 | plugins: [require('tailwindcss-animate')], 25 | }; 26 | -------------------------------------------------------------------------------- /apps/picsharp-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": false, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "noFallthroughCasesInSwitch": false, 22 | 23 | "baseUrl": ".", 24 | "paths": { 25 | "*": ["types/*"], 26 | "@/*": ["./src/*"] 27 | }, 28 | "typeRoots": ["./node_modules/@types", "./src/types"] 29 | }, 30 | "include": ["src"], 31 | "references": [{ "path": "./tsconfig.node.json" }] 32 | } 33 | -------------------------------------------------------------------------------- /apps/picsharp-app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/picsharp-app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import path from 'node:path'; 4 | 5 | const host = process.env.TAURI_DEV_HOST; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig(async () => ({ 9 | plugins: [react()], 10 | 11 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 12 | // 13 | // 1. prevent vite from obscuring rust errors 14 | clearScreen: false, 15 | server: { 16 | port: 1420, 17 | strictPort: true, 18 | host: host || false, 19 | hmr: host 20 | ? { 21 | protocol: 'ws', 22 | host, 23 | port: 1421, 24 | } 25 | : undefined, 26 | watch: { 27 | ignored: ['**/src-tauri/**'], 28 | }, 29 | }, 30 | resolve: { 31 | alias: { 32 | '@': path.resolve(__dirname, './src'), 33 | }, 34 | }, 35 | })); 36 | -------------------------------------------------------------------------------- /doc/Local-Compress&TinyPNG.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/Local-Compress&TinyPNG.png -------------------------------------------------------------------------------- /doc/Powerful-Batch-Processing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/Powerful-Batch-Processing.png -------------------------------------------------------------------------------- /doc/Watch-Mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/Watch-Mode.png -------------------------------------------------------------------------------- /doc/finder-compress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/finder-compress.png -------------------------------------------------------------------------------- /doc/finder-watch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/finder-watch.png -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AkiraBit/PicSharp/5cb449a3913486d75747d0d9dbb6dc11050843ab/doc/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@picsharp/monorepo", 3 | "private": true, 4 | "author": { 5 | "name": "AkiraBit", 6 | "email": "fengyvxiu@gmail.com" 7 | }, 8 | "license": "AGPL-3.0", 9 | "repository": "AkiraBit/PicSharp", 10 | "homepage": "https://github.com/AkiraBit/PicSharp", 11 | "bugs": { 12 | "url": "https://github.com/AkiraBit/PicSharp/issues" 13 | }, 14 | "scripts": { 15 | "dev:sidecar": "pnpm --filter @picsharp/picsharp-sidecar dev", 16 | "dev:app": "pnpm --filter @picsharp/picsharp-app tauri dev" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20.11.17", 20 | "eslint": "^9", 21 | "eslint-config-prettier": "^9.1.0", 22 | "husky": "^9.1.6", 23 | "prettier": "^3.3.3", 24 | "prettier-plugin-tailwindcss": "^0.6.8", 25 | "typescript": "^5" 26 | }, 27 | "engines": { 28 | "node": ">=20", 29 | "pnpm": ">=9" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/.gitignore: -------------------------------------------------------------------------------- 1 | /bin -------------------------------------------------------------------------------- /packages/picsharp-sidecar/README.md: -------------------------------------------------------------------------------- 1 | # PicSharp Sidecar 2 | 3 | PicSharp Sidecar is a tool that allows you to use PicSharp in a sidecar way. 4 | 5 | ## environment 6 | 7 | - node: ^20 8 | - sharp: ^0.34.1 9 | 10 | `../../node_modules/@img/sharp-${runtimePlatform}/lib/sharp-${runtimePlatform}.node` 11 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@picsharp/picsharp-sidecar", 3 | "version": "1.0.0", 4 | "description": "PicSharp Sidecar", 5 | "author": "AkiraBit", 6 | "license": "AGPL-3.0", 7 | "repository": "AkiraBit/PicSharp", 8 | "homepage": "https://github.com/AkiraBit/PicSharp", 9 | "bugs": { 10 | "url": "https://github.com/AkiraBit/PicSharp/issues" 11 | }, 12 | "private": true, 13 | "scripts": { 14 | "dev": "tsx watch src/index.ts", 15 | "build": "tsc", 16 | "build-sea:macos-arm64": "tsc && pkg --targets node20-macos-arm64 --compress gzip --output ./bin/picsharp-sidecar-aarch64-apple-darwin .", 17 | "build-sea:macos-x64": "tsc && pkg --targets node20-macos-x64 --compress gzip --output ./bin/picsharp-sidecar-x86_64-apple-darwin .", 18 | "build-sea:win-x64": "tsc && pkg --targets node20-win-x64 --compress gzip --output ./bin/picsharp-sidecar-x86_64-pc-windows-msvc .", 19 | "build-sea:win-arm64": "tsc && pkg --targets node20-win-arm64 --compress gzip --output ./bin/picsharp-sidecar-aarch64-pc-windows-msvc .", 20 | "build-sea:linux-x64": "tsc && pkg --targets node20-linux-x64 --no-bytecode --public-packages \"*\" --public --compress gzip --output ./bin/picsharp-sidecar-x86_64-unknown-linux-gnu .", 21 | "build-sea:linux-arm64": "tsc && pkg --targets node20-linux-arm64 --no-bytecode --public-packages \"*\" --public --compress gzip --output ./bin/picsharp-sidecar-aarch64-unknown-linux-gnu ." 22 | }, 23 | "dependencies": { 24 | "@hono/node-server": "^1.14.1", 25 | "@hono/zod-validator": "^0.4.3", 26 | "fs-extra": "^11.3.0", 27 | "hono": "^4.7.7", 28 | "mime": "^4.0.7", 29 | "nanoid": "^3.3.11", 30 | "sharp": "0.34.2", 31 | "svgo": "^3.3.2", 32 | "undici": "^7.8.0", 33 | "yargs": "^17.7.2", 34 | "zod": "^3.24.3" 35 | }, 36 | "devDependencies": { 37 | "@types/fs-extra": "^11.0.4", 38 | "@types/yargs": "^17.0.33", 39 | "@yao-pkg/pkg": "^6.4.0", 40 | "tsx": "^4.7.1", 41 | "typescript": "^5" 42 | }, 43 | "bin": "dist/index.js", 44 | "pkg": { 45 | "assets": [ 46 | "node_modules/@img/**/*" 47 | ] 48 | }, 49 | "engines": { 50 | "node": ">=20" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum SaveMode { 2 | Overwrite = "overwrite", 3 | SaveAsNewFile = "save_as_new_file", 4 | SaveToNewFolder = "save_to_new_folder", 5 | } 6 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/src/controllers/compress/avif.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { writeFile, copyFile } from 'node:fs/promises'; 3 | import sharp from 'sharp'; 4 | import { zValidator } from '@hono/zod-validator'; 5 | import { z } from 'zod'; 6 | import { 7 | calCompressionRate, 8 | checkFile, 9 | getFileSize, 10 | createOutputPath, 11 | copyFileToTemp, 12 | isWindows, 13 | isValidArray, 14 | } from '../../utils'; 15 | import { SaveMode } from '../../constants'; 16 | import { bulkConvert, ConvertFormat } from '../../services/convert'; 17 | 18 | const app = new Hono(); 19 | 20 | const OptionsSchema = z 21 | .object({ 22 | limit_compress_rate: z.number().min(0).max(1).optional(), 23 | save: z 24 | .object({ 25 | mode: z.nativeEnum(SaveMode).optional().default(SaveMode.Overwrite), 26 | new_file_suffix: z.string().optional().default('_compressed'), 27 | new_folder_path: z.string().optional(), 28 | }) 29 | .optional() 30 | .default({}), 31 | temp_dir: z.string().optional(), 32 | convert_types: z.array(z.nativeEnum(ConvertFormat)).optional().default([]), 33 | convert_alpha: z.string().optional().default('#FFFFFF'), 34 | }) 35 | .optional() 36 | .default({}); 37 | 38 | enum BitDepthEnum { 39 | Eight = 8, 40 | Ten = 10, 41 | Twelve = 12, 42 | } 43 | 44 | const ProcessOptionsSchema = z 45 | .object({ 46 | // 质量,整数1-100 47 | quality: z.number().min(1).max(100).optional().default(50), 48 | // 使用无损压缩模式 49 | lossless: z.boolean().optional().default(false), 50 | // CPU努力程度,介于0(最快)和9(最慢)之间 51 | effort: z.number().min(0).max(9).optional().default(4), 52 | // 色度子采样,设置为'4:2:0'以使用色度子采样,默认为'4:4:4' 53 | chromaSubsampling: z.string().optional().default('4:4:4'), 54 | // 位深度,设置为8、10或12位 55 | bitdepth: z.nativeEnum(BitDepthEnum).optional().default(BitDepthEnum.Eight), 56 | }) 57 | .optional() 58 | .default({}); 59 | 60 | const PayloadSchema = z.object({ 61 | input_path: z.string(), 62 | options: OptionsSchema, 63 | process_options: ProcessOptionsSchema, 64 | }); 65 | 66 | app.post('/', zValidator('json', PayloadSchema), async (context) => { 67 | let { input_path, options, process_options } = 68 | await context.req.json>(); 69 | await checkFile(input_path); 70 | options = OptionsSchema.parse(options); 71 | process_options = ProcessOptionsSchema.parse(process_options); 72 | const originalSize = await getFileSize(input_path); 73 | 74 | if (isWindows && options.save.mode === SaveMode.Overwrite) { 75 | sharp.cache(false); 76 | } 77 | 78 | const compressedImageBuffer = await sharp(input_path, { 79 | limitInputPixels: false, 80 | }) 81 | .avif(process_options) 82 | .toBuffer(); 83 | const compressedSize = compressedImageBuffer.byteLength; 84 | const compressionRate = calCompressionRate(originalSize, compressedSize); 85 | const availableCompressRate = compressionRate >= (options.limit_compress_rate || 0); 86 | 87 | const newOutputPath = await createOutputPath(input_path, { 88 | mode: options.save.mode, 89 | new_file_suffix: options.save.new_file_suffix, 90 | new_folder_path: options.save.new_folder_path, 91 | }); 92 | 93 | const tempFilePath = options.temp_dir ? await copyFileToTemp(input_path, options.temp_dir) : ''; 94 | 95 | if (availableCompressRate) { 96 | await writeFile(newOutputPath, compressedImageBuffer); 97 | } else { 98 | if (input_path !== newOutputPath) { 99 | await copyFile(input_path, newOutputPath); 100 | } 101 | } 102 | 103 | const result: Record = { 104 | input_path, 105 | input_size: originalSize, 106 | output_path: newOutputPath, 107 | output_size: availableCompressRate ? compressedSize : originalSize, 108 | compression_rate: availableCompressRate ? compressionRate : 0, 109 | original_temp_path: tempFilePath, 110 | available_compress_rate: availableCompressRate, 111 | debug: { 112 | compressedSize, 113 | compressionRate, 114 | options, 115 | process_options, 116 | }, 117 | }; 118 | 119 | if (isValidArray(options.convert_types)) { 120 | const results = await bulkConvert(newOutputPath, options.convert_types, options.convert_alpha); 121 | result.convert_results = results; 122 | } 123 | 124 | return context.json(result); 125 | }); 126 | 127 | export default app; 128 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/src/controllers/compress/svg.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { optimize } from 'svgo'; 3 | import type { Config } from 'svgo'; 4 | import { readFile, writeFile, copyFile } from 'node:fs/promises'; 5 | import { zValidator } from '@hono/zod-validator'; 6 | import { z } from 'zod'; 7 | import { calCompressionRate, checkFile, createOutputPath, copyFileToTemp } from '../../utils'; 8 | import { SaveMode } from '../../constants'; 9 | const app = new Hono(); 10 | 11 | const defaultSvgoConfigs: Config = { 12 | multipass: true, 13 | plugins: [{ name: 'preset-default', params: {} }], 14 | }; 15 | 16 | const OptionsSchema = z 17 | .object({ 18 | limit_compress_rate: z.number().min(0).max(1).optional(), 19 | save: z 20 | .object({ 21 | mode: z.nativeEnum(SaveMode).optional().default(SaveMode.Overwrite), 22 | new_file_suffix: z.string().optional().default('_compressed'), 23 | new_folder_path: z.string().optional(), 24 | }) 25 | .optional() 26 | .default({}), 27 | temp_dir: z.string().optional(), 28 | }) 29 | .optional() 30 | .default({}); 31 | 32 | const PayloadSchema = z.object({ 33 | input_path: z.string(), 34 | options: OptionsSchema, 35 | }); 36 | 37 | app.post('/', zValidator('json', PayloadSchema), async (context) => { 38 | let { input_path, options } = await context.req.json>(); 39 | await checkFile(input_path); 40 | options = OptionsSchema.parse(options); 41 | 42 | const originalContent = await readFile(input_path, 'utf-8'); 43 | const optimizedContent = optimize(originalContent, defaultSvgoConfigs); 44 | const compressRatio = calCompressionRate(originalContent.length, optimizedContent.data.length); 45 | 46 | const availableCompressRate = compressRatio >= (options.limit_compress_rate || 0); 47 | 48 | const newOutputPath = await createOutputPath(input_path, { 49 | mode: options.save.mode, 50 | new_file_suffix: options.save.new_file_suffix, 51 | new_folder_path: options.save.new_folder_path, 52 | }); 53 | 54 | const tempFilePath = options.temp_dir ? await copyFileToTemp(input_path, options.temp_dir) : ''; 55 | if (availableCompressRate) { 56 | await writeFile(newOutputPath, optimizedContent.data); 57 | } else { 58 | if (input_path !== newOutputPath) { 59 | await copyFile(input_path, newOutputPath); 60 | } 61 | } 62 | 63 | return context.json({ 64 | input_path, 65 | input_size: originalContent.length, 66 | output_path: newOutputPath, 67 | output_size: availableCompressRate ? optimizedContent.data.length : originalContent.length, 68 | compression_rate: availableCompressRate ? compressRatio : 0, 69 | original_temp_path: tempFilePath, 70 | available_compress_rate: availableCompressRate, 71 | debug: { 72 | compressedSize: optimizedContent.data.length, 73 | compressionRate: compressRatio, 74 | options, 75 | }, 76 | }); 77 | }); 78 | 79 | export default app; 80 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/src/index.ts: -------------------------------------------------------------------------------- 1 | import { serve } from '@hono/node-server'; 2 | import { Hono } from 'hono'; 3 | import yargs from 'yargs'; 4 | import { hideBin } from 'yargs/helpers'; 5 | import { logger } from 'hono/logger'; 6 | import { cors } from 'hono/cors'; 7 | import { timeout } from 'hono/timeout'; 8 | import svg from './controllers/compress/svg'; 9 | import jpeg from './controllers/compress/jpeg'; 10 | import png from './controllers/compress/png'; 11 | import webp from './controllers/compress/webp'; 12 | import gif from './controllers/compress/gif'; 13 | import avif from './controllers/compress/avif'; 14 | import tiff from './controllers/compress/tiff'; 15 | import tinify from './controllers/compress/tinify'; 16 | import { findAvailablePort } from './utils'; 17 | import { HTTPException } from 'hono/http-exception'; 18 | 19 | async function main() { 20 | const argv = yargs(hideBin(process.argv)) 21 | .locale('en') 22 | .option('port', { 23 | alias: 'p', 24 | description: 'Server port', 25 | type: 'number', 26 | default: 3000, 27 | }) 28 | .help() 29 | .alias('help', 'h') 30 | .parseSync(); 31 | 32 | const PORT = await findAvailablePort(argv.port); 33 | 34 | const app = new Hono() 35 | .use(logger()) 36 | .use('*', cors()) 37 | .use( 38 | '*', 39 | timeout(30000, (context) => { 40 | return new HTTPException(500, { 41 | message: `Process timeout. Please try again.`, 42 | }); 43 | }), 44 | ) 45 | .onError((err, c) => { 46 | console.error('[ERROR Catch]', err); 47 | return c.json( 48 | { 49 | status: 500, 50 | message: err.message || err.toString() || 'Internal Server Error', 51 | }, 52 | 500, 53 | ); 54 | }) 55 | .get('/', (c) => { 56 | return c.text('Picsharp Sidecar'); 57 | }) 58 | .get('/ping', (c) => { 59 | return c.text('pong'); 60 | }) 61 | .route('/compress/svg', svg) 62 | .route('/compress/jpeg', jpeg) 63 | .route('/compress/png', png) 64 | .route('/compress/webp', webp) 65 | .route('/compress/gif', gif) 66 | .route('/compress/avif', avif) 67 | .route('/compress/tiff', tiff) 68 | .route('/compress/tinify', tinify); 69 | 70 | serve( 71 | { 72 | fetch: app.fetch, 73 | port: PORT, 74 | }, 75 | (info) => { 76 | console.log( 77 | JSON.stringify({ 78 | origin: `http://localhost:${info.port}`, 79 | }), 80 | ); 81 | }, 82 | ); 83 | } 84 | 85 | main(); 86 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/src/services/convert.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import { getFileExtWithoutDot, createExtOutputPath } from '../utils'; 3 | 4 | export enum ConvertFormat { 5 | PNG = 'png', 6 | JPG = 'jpg', 7 | WEBP = 'webp', 8 | AVIF = 'avif', 9 | } 10 | 11 | export async function convert(inputPath: string, type: ConvertFormat, alpha: string) { 12 | try { 13 | const outputPath = createExtOutputPath(inputPath, type); 14 | let result = null; 15 | switch (type) { 16 | case ConvertFormat.PNG: 17 | result = await sharp(inputPath).toFormat('png').toFile(outputPath); 18 | break; 19 | case ConvertFormat.JPG: 20 | result = await sharp(inputPath) 21 | .flatten({ background: alpha }) 22 | .toFormat('jpg') 23 | .toFile(outputPath); 24 | break; 25 | case ConvertFormat.WEBP: 26 | result = await sharp(inputPath).toFormat('webp').toFile(outputPath); 27 | break; 28 | case ConvertFormat.AVIF: 29 | result = await sharp(inputPath).toFormat('avif').toFile(outputPath); 30 | break; 31 | default: 32 | throw new Error(`Unsupported convert format: ${type}`); 33 | } 34 | return { 35 | success: true, 36 | output_path: outputPath, 37 | format: type, 38 | info: result, 39 | }; 40 | } catch (error: any) { 41 | return { 42 | success: false, 43 | format: type, 44 | error_msg: error instanceof Error ? error.message : error.toString(), 45 | }; 46 | } 47 | } 48 | 49 | export async function bulkConvert(inputPath: string, types: ConvertFormat[], alpha: string) { 50 | const tasks = []; 51 | const ext = getFileExtWithoutDot(inputPath); 52 | for (const type of types) { 53 | if (ext === 'jpeg' && type === ConvertFormat.JPG) { 54 | continue; 55 | } else if (ext !== type) { 56 | tasks.push(convert(inputPath, type, alpha)); 57 | } 58 | } 59 | return Promise.all(tasks); 60 | } 61 | -------------------------------------------------------------------------------- /packages/picsharp-sidecar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "strict": true, 6 | "skipLibCheck": true, 7 | "types": ["node"], 8 | "jsx": "react-jsx", 9 | "jsxImportSource": "hono/jsx", 10 | "outDir": "./dist", 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "removeComments": true 14 | }, 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'packages/*' 4 | --------------------------------------------------------------------------------