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