mjpic 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.trae/documents/mjpic-prd.md +111 -0
  2. package/.trae/documents/mjpic-technical-architecture.md +234 -0
  3. package/README.md +57 -0
  4. package/api/app.ts +60 -0
  5. package/api/cli.ts +61 -0
  6. package/api/index.ts +19 -0
  7. package/api/routes/auth.ts +33 -0
  8. package/api/routes/image.ts +27 -0
  9. package/api/server.ts +45 -0
  10. package/dist/cli/app.js +43 -0
  11. package/dist/cli/cli.js +49 -0
  12. package/dist/cli/index.js +13 -0
  13. package/dist/cli/routes/auth.js +28 -0
  14. package/dist/cli/routes/image.js +21 -0
  15. package/dist/cli/server.js +38 -0
  16. package/dist/client/assets/index-BUIYLOn-.js +197 -0
  17. package/dist/client/assets/index-BoiS81Ei.css +1 -0
  18. package/dist/client/favicon.svg +4 -0
  19. package/dist/client/index.html +354 -0
  20. package/eslint.config.js +28 -0
  21. package/index.html +24 -0
  22. package/nodemon.json +10 -0
  23. package/package.json +68 -0
  24. package/postcss.config.js +10 -0
  25. package/public/favicon.svg +4 -0
  26. package/src/App.tsx +13 -0
  27. package/src/assets/react.svg +1 -0
  28. package/src/components/Empty.tsx +8 -0
  29. package/src/components/dialogs/AspectRatioDialog.tsx +218 -0
  30. package/src/components/dialogs/SaveDialog.tsx +150 -0
  31. package/src/components/layout/CanvasArea.tsx +874 -0
  32. package/src/components/layout/Header.tsx +156 -0
  33. package/src/components/layout/RightPanel.tsx +886 -0
  34. package/src/components/layout/Sidebar.tsx +36 -0
  35. package/src/components/layout/StatusBar.tsx +44 -0
  36. package/src/hooks/useDebounce.ts +17 -0
  37. package/src/hooks/useTheme.ts +29 -0
  38. package/src/i18n/index.ts +26 -0
  39. package/src/i18n/locales/en.json +56 -0
  40. package/src/i18n/locales/zh.json +59 -0
  41. package/src/index.css +14 -0
  42. package/src/lib/utils.ts +73 -0
  43. package/src/main.tsx +11 -0
  44. package/src/pages/Home.tsx +72 -0
  45. package/src/store/useImageStore.ts +316 -0
  46. package/src/store/usePresetStore.ts +65 -0
  47. package/src/store/useUIStore.ts +17 -0
  48. package/src/vite-env.d.ts +1 -0
  49. package/tailwind.config.js +13 -0
  50. package/tmp/guangxi.jpg +0 -0
  51. package/tsconfig.json +40 -0
  52. package/tsconfig.server.json +15 -0
  53. package/vercel.json +12 -0
  54. package/vite.config.ts +50 -0
  55. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.47.45_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/351/242/204/350/256/276/345/260/272/345/257/270.jpg +0 -0
  56. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.47.51_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/346/211/213/345/267/245/350/276/223/345/205/245/345/260/272/345/257/270.jpg +0 -0
  57. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.54.56_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/346/267/273/345/212/240/345/270/270/347/224/250/345/260/272/345/257/270.jpg +0 -0
  58. package//345/217/202/350/200/203/345/233/276/347/211/207//346/210/252/345/261/2172026-02-18 16.55.11_/345/233/276/347/211/207/345/260/272/345/257/270/350/260/203/350/212/202_/345/210/240/351/231/244/345/270/270/347/224/250/345/260/272/345/257/270.jpg +0 -0
@@ -0,0 +1,316 @@
1
+ import { create } from 'zustand';
2
+
3
+ export interface ImageConfig {
4
+ rotation: number;
5
+ scale: number;
6
+ brightness: number;
7
+ contrast: number;
8
+ saturation: number;
9
+ sharpness: number;
10
+ enhancements: {
11
+ autoEnhance: boolean;
12
+ fillLight: boolean;
13
+ autoWhiteBalance: boolean;
14
+ };
15
+ resize?: {
16
+ width: number;
17
+ height?: number;
18
+ maintainAspectRatio: boolean;
19
+ };
20
+ crop?: {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ aspectRatio?: string;
26
+ };
27
+ border?: BorderConfig;
28
+ straighten?: {
29
+ angle: number;
30
+ line?: {
31
+ x1: number;
32
+ y1: number;
33
+ x2: number;
34
+ y2: number;
35
+ };
36
+ };
37
+ }
38
+
39
+ export interface BorderConfig {
40
+ color: string;
41
+ size: number; // 0-300 percentage
42
+ applyHorizontal: boolean; // Left/Right
43
+ applyVertical: boolean; // Top/Bottom
44
+ }
45
+
46
+ interface HistoryItem {
47
+ config: ImageConfig;
48
+ sourceImage: string; // The underlying image data (base64 or URL)
49
+ width: number;
50
+ height: number;
51
+ }
52
+
53
+ interface ImageState {
54
+ originalImage: string | null; // The very first original image loaded (for absolute reset)
55
+ previewImage: string | null; // Current working image (source for Konva)
56
+ fileName: string | null;
57
+ imagePath: string | null;
58
+ originalWidth: number;
59
+ originalHeight: number;
60
+ config: ImageConfig;
61
+ history: HistoryItem[];
62
+ historyIndex: number;
63
+ cropRect: { x: number; y: number; width: number; height: number };
64
+
65
+ // Actions
66
+ loadImage: (url: string, fileName?: string, imagePath?: string) => void;
67
+ setOriginalSize: (width: number, height: number) => void;
68
+ updateConfig: (partialConfig: Partial<ImageConfig>) => void;
69
+ setCropRect: (rect: { x: number; y: number; width: number; height: number }) => void;
70
+ applyCrop: () => void;
71
+ undo: () => void;
72
+ redo: () => void;
73
+ reset: () => void;
74
+ }
75
+
76
+ const defaultConfig: ImageConfig = {
77
+ rotation: 0,
78
+ scale: 1,
79
+ brightness: 0,
80
+ contrast: 0,
81
+ saturation: 0,
82
+ sharpness: 0,
83
+ enhancements: {
84
+ autoEnhance: false,
85
+ fillLight: false,
86
+ autoWhiteBalance: false,
87
+ },
88
+ border: {
89
+ color: '#ffffff',
90
+ size: 0,
91
+ applyHorizontal: true,
92
+ applyVertical: false
93
+ }
94
+ };
95
+
96
+ export const useImageStore = create<ImageState>((set, get) => ({
97
+ originalImage: null,
98
+ previewImage: null,
99
+ fileName: null,
100
+ imagePath: null,
101
+ originalWidth: 0,
102
+ originalHeight: 0,
103
+ config: defaultConfig,
104
+ history: [], // Initialize empty, will be populated in loadImage
105
+ historyIndex: -1,
106
+ cropRect: { x: 0, y: 0, width: 0, height: 0 },
107
+
108
+ loadImage: (url, fileName, imagePath) => set({
109
+ originalImage: url,
110
+ previewImage: url,
111
+ fileName: fileName || null,
112
+ imagePath: imagePath || null,
113
+ originalWidth: 0,
114
+ originalHeight: 0,
115
+ config: defaultConfig,
116
+ history: [{
117
+ config: defaultConfig,
118
+ sourceImage: url,
119
+ width: 0,
120
+ height: 0
121
+ }],
122
+ historyIndex: 0,
123
+ cropRect: { x: 0, y: 0, width: 0, height: 0 }
124
+ }),
125
+
126
+ setOriginalSize: (width, height) => {
127
+ const { history, historyIndex } = get();
128
+ // Update the current history item with dimensions if they are 0
129
+ if (history[historyIndex] && history[historyIndex].width === 0) {
130
+ const newHistory = [...history];
131
+ newHistory[historyIndex] = { ...newHistory[historyIndex], width, height };
132
+ set({
133
+ originalWidth: width,
134
+ originalHeight: height,
135
+ history: newHistory
136
+ });
137
+ } else {
138
+ set({ originalWidth: width, originalHeight: height });
139
+ }
140
+ },
141
+
142
+ updateConfig: (partialConfig) => {
143
+ const { config, history, historyIndex, previewImage, originalWidth, originalHeight } = get();
144
+
145
+ const newConfig = { ...config, ...partialConfig };
146
+ if (partialConfig.enhancements) {
147
+ newConfig.enhancements = { ...config.enhancements, ...partialConfig.enhancements };
148
+ }
149
+
150
+ // Create new history item
151
+ const newHistoryItem: HistoryItem = {
152
+ config: newConfig,
153
+ sourceImage: previewImage || '',
154
+ width: originalWidth,
155
+ height: originalHeight
156
+ };
157
+
158
+ const newHistory = history.slice(0, historyIndex + 1);
159
+ newHistory.push(newHistoryItem);
160
+
161
+ set({
162
+ config: newConfig,
163
+ history: newHistory,
164
+ historyIndex: newHistory.length - 1
165
+ });
166
+ },
167
+
168
+ setCropRect: (rect) => set({ cropRect: rect }),
169
+
170
+ applyCrop: () => {
171
+ const { previewImage, cropRect, config, originalWidth, originalHeight } = get();
172
+ if (!previewImage || cropRect.width === 0 || cropRect.height === 0) return;
173
+
174
+ const img = new Image();
175
+ img.crossOrigin = 'anonymous';
176
+ img.onload = () => {
177
+ const canvas = document.createElement('canvas');
178
+ canvas.width = cropRect.width;
179
+ canvas.height = cropRect.height;
180
+ const ctx = canvas.getContext('2d');
181
+
182
+ if (ctx) {
183
+ // High quality smoothing
184
+ ctx.imageSmoothingEnabled = true;
185
+ ctx.imageSmoothingQuality = 'high';
186
+
187
+ // 1. Determine actual draw size (handle resize config)
188
+ let drawWidth = originalWidth;
189
+ let drawHeight = originalHeight;
190
+ if (config.resize && config.resize.width > 0 && config.resize.height > 0) {
191
+ drawWidth = config.resize.width;
192
+ drawHeight = config.resize.height;
193
+ }
194
+
195
+ // 2. Calculate border
196
+ const { size = 0, applyHorizontal = true, applyVertical = false, color = '#ffffff' } = config.border || {};
197
+ let borderW = 0;
198
+ let borderH = 0;
199
+ if (size > 0) {
200
+ if (applyHorizontal) borderW = drawWidth * (size / 100);
201
+ if (applyVertical) borderH = drawHeight * (size / 100);
202
+ }
203
+
204
+ const totalWidth = drawWidth + borderW * 2;
205
+ const totalHeight = drawHeight + borderH * 2;
206
+
207
+ // 3. Setup centers
208
+ const contentCx = totalWidth / 2;
209
+ const contentCy = totalHeight / 2;
210
+
211
+ // Crop center (relative to Total Content top-left)
212
+ const cropCx = cropRect.x + cropRect.width / 2;
213
+ const cropCy = cropRect.y + cropRect.height / 2;
214
+
215
+ // Offset
216
+ const dx = cropCx - contentCx;
217
+ const dy = cropCy - contentCy;
218
+
219
+ // 4. Draw
220
+ ctx.translate(canvas.width / 2, canvas.height / 2);
221
+ ctx.translate(-dx, -dy);
222
+ ctx.rotate((config.rotation * Math.PI) / 180);
223
+
224
+ // Draw Border Background
225
+ if (borderW > 0 || borderH > 0) {
226
+ ctx.fillStyle = color;
227
+ ctx.fillRect(-totalWidth/2, -totalHeight/2, totalWidth, totalHeight);
228
+ }
229
+
230
+ // Draw Image (Centered)
231
+ ctx.drawImage(img, -drawWidth/2, -drawHeight/2, drawWidth, drawHeight);
232
+
233
+ const croppedImage = canvas.toDataURL('image/png');
234
+
235
+ // Reset config related to crop AND rotation AND border
236
+ const newConfig = {
237
+ ...config,
238
+ crop: undefined,
239
+ resize: undefined,
240
+ rotation: 0,
241
+ border: { ...config.border, size: 0 } // Reset border size to 0 as it's baked in
242
+ };
243
+
244
+ const { history, historyIndex } = get();
245
+
246
+ const newHistoryItem: HistoryItem = {
247
+ config: newConfig,
248
+ sourceImage: croppedImage,
249
+ width: cropRect.width,
250
+ height: cropRect.height
251
+ };
252
+
253
+ const newHistory = history.slice(0, historyIndex + 1);
254
+ newHistory.push(newHistoryItem);
255
+
256
+ set({
257
+ previewImage: croppedImage,
258
+ originalWidth: cropRect.width,
259
+ originalHeight: cropRect.height,
260
+ config: newConfig,
261
+ cropRect: { x: 0, y: 0, width: 0, height: 0 },
262
+ history: newHistory,
263
+ historyIndex: newHistory.length - 1
264
+ });
265
+ }
266
+ };
267
+ img.src = previewImage;
268
+ },
269
+
270
+ undo: () => {
271
+ const { historyIndex, history } = get();
272
+ if (historyIndex > 0) {
273
+ const prevItem = history[historyIndex - 1];
274
+ set({
275
+ historyIndex: historyIndex - 1,
276
+ config: prevItem.config,
277
+ previewImage: prevItem.sourceImage,
278
+ originalWidth: prevItem.width,
279
+ originalHeight: prevItem.height
280
+ });
281
+ }
282
+ },
283
+
284
+ redo: () => {
285
+ const { historyIndex, history } = get();
286
+ if (historyIndex < history.length - 1) {
287
+ const nextItem = history[historyIndex + 1];
288
+ set({
289
+ historyIndex: historyIndex + 1,
290
+ config: nextItem.config,
291
+ previewImage: nextItem.sourceImage,
292
+ originalWidth: nextItem.width,
293
+ originalHeight: nextItem.height
294
+ });
295
+ }
296
+ },
297
+
298
+ reset: () => {
299
+ const { originalImage } = get();
300
+ // Reset to the very first state
301
+ if (originalImage) {
302
+ set({
303
+ previewImage: originalImage,
304
+ config: defaultConfig,
305
+ history: [{
306
+ config: defaultConfig,
307
+ sourceImage: originalImage,
308
+ width: 0, // Will be updated by setOriginalSize
309
+ height: 0
310
+ }],
311
+ historyIndex: 0,
312
+ cropRect: { x: 0, y: 0, width: 0, height: 0 }
313
+ });
314
+ }
315
+ }
316
+ }));
@@ -0,0 +1,65 @@
1
+ import { create } from 'zustand';
2
+ import { persist } from 'zustand/middleware';
3
+
4
+ export interface AspectRatioPreset {
5
+ id: string;
6
+ label: string;
7
+ value: string; // "w:h" or "Free"
8
+ isDefault?: boolean; // If true, cannot be deleted/edited
9
+ }
10
+
11
+ interface PresetState {
12
+ aspectRatios: AspectRatioPreset[];
13
+
14
+ // Actions
15
+ addAspectRatio: (preset: Omit<AspectRatioPreset, 'id' | 'isDefault'>) => void;
16
+ removeAspectRatio: (id: string) => void;
17
+ updateAspectRatio: (id: string, preset: Partial<Omit<AspectRatioPreset, 'id' | 'isDefault'>>) => void;
18
+ reorderAspectRatios: (newOrder: AspectRatioPreset[]) => void;
19
+ resetToDefaults: () => void;
20
+ }
21
+
22
+ const defaultAspectRatios: AspectRatioPreset[] = [
23
+ { id: 'free', label: 'Free', value: 'Free', isDefault: true },
24
+ { id: '1:1', label: '1:1', value: '1:1', isDefault: false },
25
+ { id: '4:3', label: '4:3', value: '4:3', isDefault: false },
26
+ { id: '3:4', label: '3:4', value: '3:4', isDefault: false },
27
+ { id: '16:9', label: '16:9', value: '16:9', isDefault: false },
28
+ { id: '9:16', label: '9:16', value: '9:16', isDefault: false },
29
+ { id: '3:2', label: '3:2', value: '3:2', isDefault: false },
30
+ { id: '2:3', label: '2:3', value: '2:3', isDefault: false },
31
+ ];
32
+
33
+ export const usePresetStore = create<PresetState>()(
34
+ persist(
35
+ (set) => ({
36
+ aspectRatios: defaultAspectRatios,
37
+
38
+ addAspectRatio: (preset) => set((state) => ({
39
+ aspectRatios: [
40
+ ...state.aspectRatios,
41
+ { ...preset, id: crypto.randomUUID(), isDefault: false }
42
+ ]
43
+ })),
44
+
45
+ removeAspectRatio: (id) => set((state) => ({
46
+ aspectRatios: state.aspectRatios.filter(
47
+ (p) => p.id !== id || p.isDefault
48
+ )
49
+ })),
50
+
51
+ updateAspectRatio: (id, preset) => set((state) => ({
52
+ aspectRatios: state.aspectRatios.map((p) =>
53
+ (p.id === id && !p.isDefault) ? { ...p, ...preset } : p
54
+ )
55
+ })),
56
+
57
+ reorderAspectRatios: (newOrder) => set({ aspectRatios: newOrder }),
58
+
59
+ resetToDefaults: () => set({ aspectRatios: defaultAspectRatios }),
60
+ }),
61
+ {
62
+ name: 'mjpic-presets-v2', // Increment version to force reset defaults
63
+ }
64
+ )
65
+ );
@@ -0,0 +1,17 @@
1
+ import { create } from 'zustand';
2
+
3
+ export type ToolType = 'enhance' | 'adjust' | 'resize' | 'crop' | 'rotate' | 'border' | null;
4
+
5
+ interface UIState {
6
+ activeTool: ToolType;
7
+ isStraightenToolActive: boolean;
8
+ setActiveTool: (tool: ToolType) => void;
9
+ setStraightenToolActive: (active: boolean) => void;
10
+ }
11
+
12
+ export const useUIStore = create<UIState>((set) => ({
13
+ activeTool: 'enhance', // Default to enhance or null
14
+ isStraightenToolActive: false,
15
+ setActiveTool: (tool) => set({ activeTool: tool, isStraightenToolActive: false }),
16
+ setStraightenToolActive: (active) => set({ isStraightenToolActive: active }),
17
+ }));
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,13 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+
3
+ export default {
4
+ darkMode: "class",
5
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
6
+ theme: {
7
+ container: {
8
+ center: true,
9
+ },
10
+ extend: {},
11
+ },
12
+ plugins: [],
13
+ };
Binary file
package/tsconfig.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2020",
5
+ "useDefineForClassFields": true,
6
+ "lib": [
7
+ "ES2020",
8
+ "DOM",
9
+ "DOM.Iterable"
10
+ ],
11
+ "module": "ESNext",
12
+ "skipLibCheck": true,
13
+ "moduleResolution": "bundler",
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": false,
16
+ "moduleDetection": "force",
17
+ "noEmit": true,
18
+ "jsx": "react-jsx",
19
+ "strict": false,
20
+ "noUnusedLocals": false,
21
+ "noUnusedParameters": false,
22
+ "noFallthroughCasesInSwitch": false,
23
+ "noUncheckedSideEffectImports": false,
24
+ "forceConsistentCasingInFileNames": false,
25
+ "baseUrl": "./",
26
+ "paths": {
27
+ "@/*": [
28
+ "./src/*"
29
+ ]
30
+ },
31
+ "types": [
32
+ "node",
33
+ "express"
34
+ ]
35
+ },
36
+ "include": [
37
+ "src",
38
+ "api"
39
+ ]
40
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist/cli",
7
+ "rootDir": "api",
8
+ "noEmit": false,
9
+ "allowImportingTsExtensions": false,
10
+ "verbatimModuleSyntax": false,
11
+ "jsx": "react"
12
+ },
13
+ "include": ["api/**/*"],
14
+ "exclude": ["src", "node_modules", "dist"]
15
+ }
package/vercel.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "rewrites": [
3
+ {
4
+ "source": "/api/(.*)",
5
+ "destination": "/api/index"
6
+ },
7
+ {
8
+ "source": "/(.*)",
9
+ "destination": "/index.html"
10
+ }
11
+ ]
12
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tsconfigPaths from "vite-tsconfig-paths";
4
+ import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
5
+
6
+ // https://vite.dev/config/
7
+ export default defineConfig({
8
+ plugins: [
9
+ react({
10
+ babel: {
11
+ plugins: [
12
+ 'react-dev-locator',
13
+ ],
14
+ },
15
+ }),
16
+ traeBadgePlugin({
17
+ variant: 'dark',
18
+ position: 'bottom-right',
19
+ prodOnly: true,
20
+ clickable: true,
21
+ clickUrl: 'https://www.trae.ai/solo?showJoin=1',
22
+ autoTheme: true,
23
+ autoThemeTarget: '#root'
24
+ }),
25
+ tsconfigPaths(),
26
+ ],
27
+ server: {
28
+ proxy: {
29
+ '/api': {
30
+ target: 'http://localhost:3002',
31
+ changeOrigin: true,
32
+ secure: false,
33
+ configure: (proxy, _options) => {
34
+ proxy.on('error', (err, _req, _res) => {
35
+ console.log('proxy error', err);
36
+ });
37
+ proxy.on('proxyReq', (proxyReq, req, _res) => {
38
+ console.log('Sending Request to the Target:', req.method, req.url);
39
+ });
40
+ proxy.on('proxyRes', (proxyRes, req, _res) => {
41
+ console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
42
+ });
43
+ },
44
+ }
45
+ }
46
+ },
47
+ build: {
48
+ outDir: 'dist/client',
49
+ }
50
+ })