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.
- package/.trae/documents/mjpic-prd.md +111 -0
- package/.trae/documents/mjpic-technical-architecture.md +234 -0
- package/README.md +57 -0
- package/api/app.ts +60 -0
- package/api/cli.ts +61 -0
- package/api/index.ts +19 -0
- package/api/routes/auth.ts +33 -0
- package/api/routes/image.ts +27 -0
- package/api/server.ts +45 -0
- package/dist/cli/app.js +43 -0
- package/dist/cli/cli.js +49 -0
- package/dist/cli/index.js +13 -0
- package/dist/cli/routes/auth.js +28 -0
- package/dist/cli/routes/image.js +21 -0
- package/dist/cli/server.js +38 -0
- package/dist/client/assets/index-BUIYLOn-.js +197 -0
- package/dist/client/assets/index-BoiS81Ei.css +1 -0
- package/dist/client/favicon.svg +4 -0
- package/dist/client/index.html +354 -0
- package/eslint.config.js +28 -0
- package/index.html +24 -0
- package/nodemon.json +10 -0
- package/package.json +68 -0
- package/postcss.config.js +10 -0
- package/public/favicon.svg +4 -0
- package/src/App.tsx +13 -0
- package/src/assets/react.svg +1 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/dialogs/AspectRatioDialog.tsx +218 -0
- package/src/components/dialogs/SaveDialog.tsx +150 -0
- package/src/components/layout/CanvasArea.tsx +874 -0
- package/src/components/layout/Header.tsx +156 -0
- package/src/components/layout/RightPanel.tsx +886 -0
- package/src/components/layout/Sidebar.tsx +36 -0
- package/src/components/layout/StatusBar.tsx +44 -0
- package/src/hooks/useDebounce.ts +17 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/i18n/index.ts +26 -0
- package/src/i18n/locales/en.json +56 -0
- package/src/i18n/locales/zh.json +59 -0
- package/src/index.css +14 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +11 -0
- package/src/pages/Home.tsx +72 -0
- package/src/store/useImageStore.ts +316 -0
- package/src/store/usePresetStore.ts +65 -0
- package/src/store/useUIStore.ts +17 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +13 -0
- package/tmp/guangxi.jpg +0 -0
- package/tsconfig.json +40 -0
- package/tsconfig.server.json +15 -0
- package/vercel.json +12 -0
- package/vite.config.ts +50 -0
- 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
- 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
- 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
- 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
|
+
};
|