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,218 @@
1
+ import { useState } from 'react';
2
+ import { X, Plus, Trash2, GripVertical } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { usePresetStore, AspectRatioPreset } from '@/store/usePresetStore';
5
+ import {
6
+ DndContext,
7
+ closestCenter,
8
+ KeyboardSensor,
9
+ PointerSensor,
10
+ useSensor,
11
+ useSensors,
12
+ DragEndEvent
13
+ } from '@dnd-kit/core';
14
+ import {
15
+ arrayMove,
16
+ SortableContext,
17
+ sortableKeyboardCoordinates,
18
+ verticalListSortingStrategy,
19
+ useSortable
20
+ } from '@dnd-kit/sortable';
21
+ import { CSS } from '@dnd-kit/utilities';
22
+
23
+ interface AspectRatioDialogProps {
24
+ isOpen: boolean;
25
+ onClose: () => void;
26
+ }
27
+
28
+ interface SortableItemProps {
29
+ preset: AspectRatioPreset;
30
+ onRemove: (id: string) => void;
31
+ t: (key: string) => string;
32
+ }
33
+
34
+ const SortableItem = ({ preset, onRemove, t }: SortableItemProps) => {
35
+ const {
36
+ attributes,
37
+ listeners,
38
+ setNodeRef,
39
+ transform,
40
+ transition,
41
+ isDragging
42
+ } = useSortable({ id: preset.id });
43
+
44
+ const style = {
45
+ transform: CSS.Transform.toString(transform),
46
+ transition,
47
+ zIndex: isDragging ? 1 : 0,
48
+ position: 'relative' as const,
49
+ };
50
+
51
+ return (
52
+ <div
53
+ ref={setNodeRef}
54
+ style={style}
55
+ className={`flex justify-between items-center bg-zinc-800 p-2 rounded mb-2 ${isDragging ? 'opacity-50 ring-2 ring-blue-500' : ''}`}
56
+ >
57
+ <button
58
+ {...attributes}
59
+ {...listeners}
60
+ className="text-zinc-600 hover:text-zinc-400 cursor-grab active:cursor-grabbing mr-2 p-1"
61
+ >
62
+ <GripVertical size={14} />
63
+ </button>
64
+
65
+ <div className="flex flex-col flex-1">
66
+ <span className="text-sm text-zinc-200">
67
+ {preset.value === 'Free' ? t('panels.free') : preset.label}
68
+ </span>
69
+ {preset.label !== preset.value && preset.value !== 'Free' && (
70
+ <span className="text-xs text-zinc-500">{preset.value}</span>
71
+ )}
72
+ </div>
73
+
74
+ {!preset.isDefault && (
75
+ <button
76
+ onClick={() => onRemove(preset.id)}
77
+ className="text-zinc-500 hover:text-red-400 p-1 rounded hover:bg-zinc-700/50 transition-colors"
78
+ >
79
+ <Trash2 size={14} />
80
+ </button>
81
+ )}
82
+ {preset.isDefault && (
83
+ <span className="text-xs text-zinc-600 px-2">{t('common.default')}</span>
84
+ )}
85
+ </div>
86
+ );
87
+ };
88
+
89
+ export const AspectRatioDialog = ({ isOpen, onClose }: AspectRatioDialogProps) => {
90
+ const { t } = useTranslation();
91
+ const { aspectRatios, addAspectRatio, removeAspectRatio, reorderAspectRatios } = usePresetStore();
92
+
93
+ const [newWidth, setNewWidth] = useState('');
94
+ const [newHeight, setNewHeight] = useState('');
95
+ const [newLabel, setNewLabel] = useState('');
96
+
97
+ const sensors = useSensors(
98
+ useSensor(PointerSensor, {
99
+ activationConstraint: {
100
+ distance: 5, // Prevent accidental drags when clicking
101
+ },
102
+ }),
103
+ useSensor(KeyboardSensor, {
104
+ coordinateGetter: sortableKeyboardCoordinates,
105
+ })
106
+ );
107
+
108
+ if (!isOpen) return null;
109
+
110
+ const handleAdd = () => {
111
+ if (!newWidth || !newHeight) return;
112
+
113
+ // Validate numbers
114
+ const w = parseInt(newWidth);
115
+ const h = parseInt(newHeight);
116
+
117
+ if (isNaN(w) || isNaN(h) || w <= 0 || h <= 0) {
118
+ alert('Invalid dimensions');
119
+ return;
120
+ }
121
+
122
+ const value = `${w}:${h}`;
123
+ const label = newLabel || value;
124
+
125
+ addAspectRatio({
126
+ label,
127
+ value,
128
+ });
129
+
130
+ setNewWidth('');
131
+ setNewHeight('');
132
+ setNewLabel('');
133
+ };
134
+
135
+ const handleDragEnd = (event: DragEndEvent) => {
136
+ const { active, over } = event;
137
+
138
+ if (over && active.id !== over.id) {
139
+ const oldIndex = aspectRatios.findIndex((item) => item.id === active.id);
140
+ const newIndex = aspectRatios.findIndex((item) => item.id === over.id);
141
+
142
+ reorderAspectRatios(arrayMove(aspectRatios, oldIndex, newIndex));
143
+ }
144
+ };
145
+
146
+ return (
147
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
148
+ <div className="bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl w-96 p-4 max-h-[80vh] flex flex-col">
149
+ <div className="flex justify-between items-center mb-4">
150
+ <h3 className="text-zinc-100 font-semibold">{t('common.manageRatios')}</h3>
151
+ <button onClick={onClose} className="text-zinc-400 hover:text-white">
152
+ <X size={18} />
153
+ </button>
154
+ </div>
155
+
156
+ {/* Add New */}
157
+ <div className="bg-zinc-800/50 p-3 rounded mb-4 space-y-3">
158
+ <div className="flex gap-2 items-center">
159
+ <input
160
+ type="number"
161
+ placeholder={t('common.width')}
162
+ value={newWidth}
163
+ onChange={(e) => setNewWidth(e.target.value)}
164
+ className="w-16 bg-zinc-700 text-zinc-100 p-1.5 rounded text-sm text-center"
165
+ min="1"
166
+ />
167
+ <span className="text-zinc-400">:</span>
168
+ <input
169
+ type="number"
170
+ placeholder={t('common.height')}
171
+ value={newHeight}
172
+ onChange={(e) => setNewHeight(e.target.value)}
173
+ className="w-16 bg-zinc-700 text-zinc-100 p-1.5 rounded text-sm text-center"
174
+ min="1"
175
+ />
176
+ <button
177
+ onClick={handleAdd}
178
+ disabled={!newWidth || !newHeight}
179
+ className="ml-auto px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded text-sm flex items-center gap-1"
180
+ >
181
+ <Plus size={14} /> {t('common.add')}
182
+ </button>
183
+ </div>
184
+ <input
185
+ type="text"
186
+ placeholder={t('common.labelOptional')}
187
+ value={newLabel}
188
+ onChange={(e) => setNewLabel(e.target.value)}
189
+ className="w-full bg-zinc-700 text-zinc-100 p-1.5 rounded text-sm"
190
+ />
191
+ </div>
192
+
193
+ {/* List */}
194
+ <div className="flex-1 overflow-y-auto pr-1">
195
+ <DndContext
196
+ sensors={sensors}
197
+ collisionDetection={closestCenter}
198
+ onDragEnd={handleDragEnd}
199
+ >
200
+ <SortableContext
201
+ items={aspectRatios}
202
+ strategy={verticalListSortingStrategy}
203
+ >
204
+ {aspectRatios.map((ratio) => (
205
+ <SortableItem
206
+ key={ratio.id}
207
+ preset={ratio}
208
+ onRemove={removeAspectRatio}
209
+ t={t}
210
+ />
211
+ ))}
212
+ </SortableContext>
213
+ </DndContext>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ };
@@ -0,0 +1,150 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { X } from 'lucide-react';
3
+ import { useTranslation } from 'react-i18next';
4
+
5
+ interface SaveDialogProps {
6
+ isOpen: boolean;
7
+ onClose: () => void;
8
+ onConfirm: (format: string, quality: number, path?: string, fileName?: string) => void;
9
+ defaultPath?: string;
10
+ defaultFileName?: string;
11
+ }
12
+
13
+ export const SaveDialog = ({ isOpen, onClose, onConfirm, defaultPath, defaultFileName }: SaveDialogProps) => {
14
+ const { t } = useTranslation();
15
+ const [format, setFormat] = useState('image/jpeg');
16
+ const [quality, setQuality] = useState(85);
17
+ const [savePath, setSavePath] = useState('');
18
+ const [fileName, setFileName] = useState('');
19
+
20
+ // Reset state when dialog opens
21
+ useEffect(() => {
22
+ if (isOpen) {
23
+ setFormat('image/jpeg');
24
+ setQuality(85);
25
+
26
+ const extMap: Record<string, string> = {
27
+ 'image/jpeg': '.jpg',
28
+ 'image/png': '.png',
29
+ 'image/webp': '.webp'
30
+ };
31
+ const suffix = t('common.copySuffix', { defaultValue: '_copy' });
32
+ const ext = extMap['image/jpeg'];
33
+
34
+ if (defaultPath) {
35
+ // CLI Mode
36
+ const basePath = defaultPath.replace(/\.[^/.]+$/, '');
37
+ setSavePath(`${basePath}${suffix}${ext}`);
38
+ setFileName('');
39
+ } else {
40
+ // Web Mode
41
+ const baseName = defaultFileName ? defaultFileName.replace(/\.[^/.]+$/, '') : 'image';
42
+ setFileName(`${baseName}${suffix}${ext}`);
43
+ setSavePath('');
44
+ }
45
+ }
46
+ }, [isOpen, defaultPath, defaultFileName, t]);
47
+
48
+ // Update extension when format changes
49
+ useEffect(() => {
50
+ const extMap: Record<string, string> = {
51
+ 'image/jpeg': '.jpg',
52
+ 'image/png': '.png',
53
+ 'image/webp': '.webp'
54
+ };
55
+
56
+ const newExt = extMap[format];
57
+ if (newExt) {
58
+ if (savePath) {
59
+ setSavePath(prev => prev.replace(/\.[^/.]+$/, newExt));
60
+ }
61
+ if (fileName) {
62
+ setFileName(prev => prev.replace(/\.[^/.]+$/, newExt));
63
+ }
64
+ }
65
+ }, [format]);
66
+
67
+ if (!isOpen) return null;
68
+
69
+ return (
70
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
71
+ <div className="bg-zinc-900 border border-zinc-800 rounded-lg shadow-xl w-96 p-4">
72
+ <div className="flex justify-between items-center mb-4">
73
+ <h3 className="text-zinc-100 font-semibold">{t('common.saveSettings')}</h3>
74
+ <button onClick={onClose} className="text-zinc-400 hover:text-white">
75
+ <X size={18} />
76
+ </button>
77
+ </div>
78
+
79
+ <div className="space-y-4">
80
+ {defaultPath ? (
81
+ <div className="space-y-2">
82
+ <label className="text-xs text-zinc-400 block">{t('common.savePath')}</label>
83
+ <input
84
+ type="text"
85
+ value={savePath}
86
+ onChange={(e) => setSavePath(e.target.value)}
87
+ className="w-full bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 outline-none text-sm font-mono"
88
+ />
89
+ </div>
90
+ ) : (
91
+ <div className="space-y-2">
92
+ <label className="text-xs text-zinc-400 block">{t('common.fileName')}</label>
93
+ <input
94
+ type="text"
95
+ value={fileName}
96
+ onChange={(e) => setFileName(e.target.value)}
97
+ className="w-full bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 outline-none text-sm"
98
+ />
99
+ </div>
100
+ )}
101
+
102
+ <div className="space-y-2">
103
+ <label className="text-xs text-zinc-400 block">{t('common.format')}</label>
104
+ <select
105
+ value={format}
106
+ onChange={(e) => setFormat(e.target.value)}
107
+ className="w-full bg-zinc-800 text-zinc-100 p-2 rounded border border-zinc-700 outline-none text-sm"
108
+ >
109
+ <option value="image/jpeg">JPG</option>
110
+ <option value="image/png">PNG</option>
111
+ <option value="image/webp">WEBP</option>
112
+ </select>
113
+ </div>
114
+
115
+ {format !== 'image/png' && (
116
+ <div className="space-y-2">
117
+ <div className="flex justify-between text-xs text-zinc-400">
118
+ <span>{t('common.quality')}</span>
119
+ <span>{quality}</span>
120
+ </div>
121
+ <input
122
+ type="range"
123
+ min="1"
124
+ max="100"
125
+ value={quality}
126
+ onChange={(e) => setQuality(parseInt(e.target.value))}
127
+ className="w-full h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
128
+ />
129
+ </div>
130
+ )}
131
+
132
+ <div className="flex gap-2 mt-6">
133
+ <button
134
+ onClick={onClose}
135
+ className="flex-1 px-3 py-2 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors"
136
+ >
137
+ {t('common.cancel')}
138
+ </button>
139
+ <button
140
+ onClick={() => onConfirm(format, quality / 100, savePath, fileName)}
141
+ className="flex-1 px-3 py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm transition-colors"
142
+ >
143
+ {t('common.confirm')}
144
+ </button>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ );
150
+ };