@jhits/plugin-images 0.0.4 → 0.0.5
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/package.json +3 -16
- package/src/api/resolve/route.ts +130 -11
- package/src/api/upload/index.ts +0 -4
- package/src/assets/noimagefound.jpg +0 -0
- package/src/components/BackgroundImage.tsx +25 -44
- package/src/components/GlobalImageEditor/GlobalImageEditor.tsx +374 -0
- package/src/components/GlobalImageEditor/config.ts +21 -0
- package/src/components/GlobalImageEditor/eventHandlers.ts +267 -0
- package/src/components/GlobalImageEditor/imageDetection.ts +160 -0
- package/src/components/GlobalImageEditor/imageSetup.ts +306 -0
- package/src/components/GlobalImageEditor/saveLogic.ts +133 -0
- package/src/components/GlobalImageEditor/stylingDetection.ts +122 -0
- package/src/components/GlobalImageEditor/transformParsing.ts +83 -0
- package/src/components/GlobalImageEditor/types.ts +39 -0
- package/src/components/GlobalImageEditor.tsx +185 -636
- package/src/components/Image.tsx +269 -103
- package/src/components/ImageBrowserModal.tsx +824 -0
- package/src/components/ImageEditor.tsx +323 -0
- package/src/components/ImageEffectsPanel.tsx +116 -0
- package/src/components/ImagePicker.tsx +171 -485
- package/src/components/index.ts +3 -0
- package/src/hooks/useImagePicker.ts +322 -0
- package/src/types/index.ts +24 -0
- package/src/utils/transforms.ts +54 -0
package/package.json
CHANGED
|
@@ -1,39 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhits/plugin-images",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Image management and storage plugin for the JHITS ecosystem",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"main": "./src/index.tsx",
|
|
9
9
|
"types": "./src/index.tsx",
|
|
10
|
-
"browser": {
|
|
11
|
-
"mongodb": false,
|
|
12
|
-
"server-only": false,
|
|
13
|
-
"./src/api/api-server.ts": false,
|
|
14
|
-
"./src/index.server.ts": false
|
|
15
|
-
},
|
|
16
10
|
"exports": {
|
|
17
11
|
".": {
|
|
18
12
|
"types": "./src/index.tsx",
|
|
19
|
-
"import": "./src/index.tsx",
|
|
20
13
|
"default": "./src/index.tsx"
|
|
21
14
|
},
|
|
22
15
|
"./server": {
|
|
23
16
|
"types": "./src/index.server.ts",
|
|
24
|
-
"import": "./src/index.server.ts",
|
|
25
17
|
"default": "./src/index.server.ts"
|
|
26
|
-
},
|
|
27
|
-
"./components/*": {
|
|
28
|
-
"types": "./src/components/*.tsx",
|
|
29
|
-
"default": "./src/components/*.tsx"
|
|
30
18
|
}
|
|
31
19
|
},
|
|
32
20
|
"dependencies": {
|
|
33
|
-
"@jhits/plugin-core": "^0.0.1",
|
|
34
21
|
"mongodb": "^7.0.0",
|
|
35
22
|
"lucide-react": "^0.562.0",
|
|
36
|
-
"
|
|
23
|
+
"@jhits/plugin-core": "0.0.1"
|
|
37
24
|
},
|
|
38
25
|
"peerDependencies": {
|
|
39
26
|
"next": ">=15.0.0",
|
|
@@ -57,4 +44,4 @@
|
|
|
57
44
|
"src",
|
|
58
45
|
"package.json"
|
|
59
46
|
]
|
|
60
|
-
}
|
|
47
|
+
}
|
package/src/api/resolve/route.ts
CHANGED
|
@@ -9,6 +9,91 @@ import path from 'path';
|
|
|
9
9
|
|
|
10
10
|
const mappingsPath = path.join(process.cwd(), 'data', 'image-mappings.json');
|
|
11
11
|
|
|
12
|
+
// Simple mutex to prevent concurrent writes
|
|
13
|
+
let writeLock = false;
|
|
14
|
+
const writeQueue: Array<() => void> = [];
|
|
15
|
+
|
|
16
|
+
async function acquireLock(): Promise<() => void> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
if (!writeLock) {
|
|
19
|
+
writeLock = true;
|
|
20
|
+
resolve(() => {
|
|
21
|
+
writeLock = false;
|
|
22
|
+
const next = writeQueue.shift();
|
|
23
|
+
if (next) next();
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
writeQueue.push(() => {
|
|
27
|
+
writeLock = true;
|
|
28
|
+
resolve(() => {
|
|
29
|
+
writeLock = false;
|
|
30
|
+
const next = writeQueue.shift();
|
|
31
|
+
if (next) next();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readMappingsSafely(): Promise<Record<string, any>> {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(mappingsPath, 'utf-8');
|
|
41
|
+
// Try to parse, if it fails, try to recover
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(content);
|
|
44
|
+
} catch (parseError) {
|
|
45
|
+
console.error('JSON parse error, attempting recovery:', parseError);
|
|
46
|
+
// Try to extract valid JSON by finding the last complete object
|
|
47
|
+
const lines = content.split('\n');
|
|
48
|
+
let validContent = '';
|
|
49
|
+
let braceCount = 0;
|
|
50
|
+
let inString = false;
|
|
51
|
+
let escapeNext = false;
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
for (let i = 0; i < line.length; i++) {
|
|
55
|
+
const char = line[i];
|
|
56
|
+
if (escapeNext) {
|
|
57
|
+
escapeNext = false;
|
|
58
|
+
validContent += char;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (char === '\\') {
|
|
62
|
+
escapeNext = true;
|
|
63
|
+
validContent += char;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === '"') {
|
|
67
|
+
inString = !inString;
|
|
68
|
+
validContent += char;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!inString) {
|
|
72
|
+
if (char === '{') braceCount++;
|
|
73
|
+
if (char === '}') braceCount--;
|
|
74
|
+
}
|
|
75
|
+
validContent += char;
|
|
76
|
+
}
|
|
77
|
+
validContent += '\n';
|
|
78
|
+
// If we've closed all braces, we might have valid JSON
|
|
79
|
+
if (braceCount === 0 && validContent.trim().endsWith('}')) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(validContent.trim());
|
|
82
|
+
} catch {
|
|
83
|
+
// Continue trying
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// If recovery failed, return empty object
|
|
88
|
+
console.error('Failed to recover JSON, using empty mappings');
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('Failed to read mappings file:', error);
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
12
97
|
async function ensureMappingsFile() {
|
|
13
98
|
try {
|
|
14
99
|
const dir = path.dirname(mappingsPath);
|
|
@@ -43,7 +128,7 @@ export async function GET(request: NextRequest) {
|
|
|
43
128
|
);
|
|
44
129
|
}
|
|
45
130
|
|
|
46
|
-
const mappings =
|
|
131
|
+
const mappings = await readMappingsSafely();
|
|
47
132
|
const mapping = mappings[id];
|
|
48
133
|
|
|
49
134
|
if (!mapping) {
|
|
@@ -57,12 +142,20 @@ export async function GET(request: NextRequest) {
|
|
|
57
142
|
const filename = typeof mapping === 'string' ? mapping : mapping.filename;
|
|
58
143
|
const brightness = typeof mapping === 'object' ? (mapping.brightness ?? 100) : 100;
|
|
59
144
|
const blur = typeof mapping === 'object' ? (mapping.blur ?? 0) : 0;
|
|
145
|
+
// Ensure scale is within valid range (0.1 to 5.0), default to 1.0 if invalid
|
|
146
|
+
const rawScale = typeof mapping === 'object' ? (mapping.scale ?? 1.0) : 1.0;
|
|
147
|
+
const scale = rawScale > 0 && rawScale <= 5.0 ? Math.max(0.1, rawScale) : 1.0;
|
|
148
|
+
const positionX = typeof mapping === 'object' ? (mapping.positionX ?? 0) : 0;
|
|
149
|
+
const positionY = typeof mapping === 'object' ? (mapping.positionY ?? 0) : 0;
|
|
60
150
|
|
|
61
151
|
return NextResponse.json({
|
|
62
152
|
id,
|
|
63
153
|
filename,
|
|
64
154
|
brightness,
|
|
65
155
|
blur,
|
|
156
|
+
scale,
|
|
157
|
+
positionX,
|
|
158
|
+
positionY,
|
|
66
159
|
url: `/api/uploads/${encodeURIComponent(filename)}`,
|
|
67
160
|
});
|
|
68
161
|
} catch (error) {
|
|
@@ -84,7 +177,7 @@ export async function POST(request: NextRequest) {
|
|
|
84
177
|
await ensureMappingsFile();
|
|
85
178
|
|
|
86
179
|
const body = await request.json();
|
|
87
|
-
const { id, filename, brightness, blur } = body;
|
|
180
|
+
const { id, filename, brightness, blur, scale, positionX, positionY } = body;
|
|
88
181
|
|
|
89
182
|
if (!id || !filename) {
|
|
90
183
|
return NextResponse.json(
|
|
@@ -93,16 +186,39 @@ export async function POST(request: NextRequest) {
|
|
|
93
186
|
);
|
|
94
187
|
}
|
|
95
188
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// Store as object with optional brightness and blur
|
|
99
|
-
mappings[id] = {
|
|
100
|
-
filename,
|
|
101
|
-
...(brightness !== undefined && { brightness }),
|
|
102
|
-
...(blur !== undefined && { blur }),
|
|
103
|
-
};
|
|
189
|
+
// Acquire write lock to prevent concurrent writes
|
|
190
|
+
const releaseLock = await acquireLock();
|
|
104
191
|
|
|
105
|
-
|
|
192
|
+
try {
|
|
193
|
+
const mappings = await readMappingsSafely();
|
|
194
|
+
|
|
195
|
+
// Store as object with optional brightness, blur, scale, and position
|
|
196
|
+
// Merge with existing values to avoid overwriting fields that aren't provided
|
|
197
|
+
// Ensure scale is within valid range (0.1 to 5.0)
|
|
198
|
+
const safeScale = scale !== undefined ? Math.max(0.1, Math.min(5.0, scale)) : undefined;
|
|
199
|
+
const existing = mappings[id] || {};
|
|
200
|
+
mappings[id] = {
|
|
201
|
+
...existing, // Preserve existing values
|
|
202
|
+
filename, // Always update filename
|
|
203
|
+
...(brightness !== undefined && { brightness }),
|
|
204
|
+
...(blur !== undefined && { blur }),
|
|
205
|
+
...(safeScale !== undefined && { scale: safeScale }),
|
|
206
|
+
...(positionX !== undefined && { positionX }),
|
|
207
|
+
...(positionY !== undefined && { positionY }),
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Write atomically using a temporary file
|
|
211
|
+
const tempPath = mappingsPath + '.tmp';
|
|
212
|
+
await writeFile(tempPath, JSON.stringify(mappings, null, 2), 'utf-8');
|
|
213
|
+
|
|
214
|
+
// Atomic rename (this is atomic on most filesystems)
|
|
215
|
+
const { rename } = await import('fs/promises');
|
|
216
|
+
await rename(tempPath, mappingsPath);
|
|
217
|
+
} finally {
|
|
218
|
+
releaseLock();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log('SAVING TO DB:', { id, scale, brightness, blur, positionX, positionY });
|
|
106
222
|
|
|
107
223
|
return NextResponse.json({
|
|
108
224
|
success: true,
|
|
@@ -110,6 +226,9 @@ export async function POST(request: NextRequest) {
|
|
|
110
226
|
filename,
|
|
111
227
|
brightness: brightness ?? 100,
|
|
112
228
|
blur: blur ?? 0,
|
|
229
|
+
scale: scale ?? 1.0,
|
|
230
|
+
positionX: positionX ?? 0,
|
|
231
|
+
positionY: positionY ?? 0,
|
|
113
232
|
});
|
|
114
233
|
} catch (error) {
|
|
115
234
|
console.error('Set mapping error:', error);
|
package/src/api/upload/index.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Image Upload API Handler
|
|
3
3
|
* POST /api/plugin-images/upload - Handles image file uploads
|
|
4
|
-
*
|
|
5
|
-
* SERVER-ONLY: This module must never be imported by client code
|
|
6
4
|
*/
|
|
7
5
|
|
|
8
|
-
import 'server-only';
|
|
9
|
-
|
|
10
6
|
import { NextRequest, NextResponse } from 'next/server';
|
|
11
7
|
import { writeFile, mkdir } from 'fs/promises';
|
|
12
8
|
import path from 'path';
|
|
File without changes
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
4
|
import { Image } from './Image';
|
|
5
|
-
import { Edit2
|
|
5
|
+
import { Edit2 } from 'lucide-react';
|
|
6
6
|
|
|
7
7
|
export interface BackgroundImageProps {
|
|
8
8
|
id: string;
|
|
@@ -11,14 +11,8 @@ export interface BackgroundImageProps {
|
|
|
11
11
|
children?: React.ReactNode;
|
|
12
12
|
backgroundSize?: 'cover' | 'contain' | 'auto' | string;
|
|
13
13
|
backgroundPosition?: string;
|
|
14
|
-
backgroundRepeat?: 'repeat' | 'no-repeat' | 'repeat-x' | 'repeat-y';
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
/**
|
|
18
|
-
* BackgroundImage Component
|
|
19
|
-
* * A container that handles a background image with admin edit capabilities.
|
|
20
|
-
* The edit button appears on hover anywhere within the container.
|
|
21
|
-
*/
|
|
22
16
|
export function BackgroundImage({
|
|
23
17
|
id,
|
|
24
18
|
className = '',
|
|
@@ -30,17 +24,16 @@ export function BackgroundImage({
|
|
|
30
24
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
31
25
|
const [isLoading, setIsLoading] = useState(true);
|
|
32
26
|
|
|
33
|
-
// Authentication check for Admin/Dev users
|
|
34
27
|
useEffect(() => {
|
|
35
28
|
const checkUser = async () => {
|
|
36
29
|
try {
|
|
37
30
|
const res = await fetch('/api/me');
|
|
38
31
|
const data = await res.json();
|
|
39
|
-
if (data.loggedIn &&
|
|
32
|
+
if (data.loggedIn && ['admin', 'dev'].includes(data.user?.role)) {
|
|
40
33
|
setIsAdmin(true);
|
|
41
34
|
}
|
|
42
35
|
} catch (error) {
|
|
43
|
-
console.error('
|
|
36
|
+
console.error('Auth error:', error);
|
|
44
37
|
} finally {
|
|
45
38
|
setIsLoading(false);
|
|
46
39
|
}
|
|
@@ -51,60 +44,48 @@ export function BackgroundImage({
|
|
|
51
44
|
const handleEditClick = (e: React.MouseEvent) => {
|
|
52
45
|
e.preventDefault();
|
|
53
46
|
e.stopPropagation();
|
|
54
|
-
// Dispatch event to the GlobalImageEditor
|
|
55
47
|
window.dispatchEvent(new CustomEvent('open-image-editor', { detail: { id } }));
|
|
56
48
|
};
|
|
57
49
|
|
|
58
50
|
return (
|
|
59
51
|
<div
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
style={style}
|
|
52
|
+
className={`group relative overflow-hidden ${className}`}
|
|
53
|
+
style={{ ...style, minHeight: style.minHeight ?? '300px' }} // Ensure visibility
|
|
54
|
+
data-background-id={id}
|
|
64
55
|
>
|
|
65
|
-
{/* 1. BACKGROUND
|
|
66
|
-
|
|
56
|
+
{/* 1. THE BACKGROUND LAYER
|
|
57
|
+
We force 'fill' and 'w-full h-full' to ensure the internal
|
|
58
|
+
Image component triggers its 'shouldFill' logic correctly.
|
|
59
|
+
*/}
|
|
60
|
+
<div className="absolute inset-0 z-0">
|
|
67
61
|
<Image
|
|
68
62
|
id={id}
|
|
69
63
|
alt=""
|
|
70
64
|
fill
|
|
65
|
+
priority
|
|
71
66
|
className="w-full h-full"
|
|
72
|
-
objectFit={backgroundSize
|
|
67
|
+
objectFit={backgroundSize as any}
|
|
73
68
|
objectPosition={backgroundPosition}
|
|
74
|
-
editable={false}
|
|
69
|
+
editable={false}
|
|
75
70
|
/>
|
|
76
71
|
</div>
|
|
77
72
|
|
|
78
|
-
{/* 2. CONTENT LAYER
|
|
73
|
+
{/* 2. CONTENT LAYER */}
|
|
79
74
|
<div className="relative z-10 w-full h-full">
|
|
80
75
|
{children}
|
|
81
76
|
</div>
|
|
82
77
|
|
|
83
|
-
{/* 3. ADMIN UI
|
|
78
|
+
{/* 3. ADMIN UI */}
|
|
84
79
|
{!isLoading && isAdmin && (
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
rounded-full shadow-2xl
|
|
95
|
-
border border-neutral-300 dark:border-neutral-700
|
|
96
|
-
cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-700
|
|
97
|
-
opacity-0 group-hover:opacity-100
|
|
98
|
-
translate-y-4 group-hover:translate-y-0
|
|
99
|
-
transition-all duration-500 ease-out
|
|
100
|
-
"
|
|
101
|
-
>
|
|
102
|
-
<Edit2 size={16} strokeWidth={2.5} className="text-primary" />
|
|
103
|
-
<span className="text-[11px] font-black uppercase tracking-[0.2em]">
|
|
104
|
-
Edit Background
|
|
105
|
-
</span>
|
|
106
|
-
</button>
|
|
107
|
-
</div>
|
|
80
|
+
<button
|
|
81
|
+
onClick={handleEditClick}
|
|
82
|
+
className="absolute bottom-8 left-8 z-50 flex items-center gap-3 px-6 py-3 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-full shadow-2xl border border-neutral-200 dark:border-neutral-700 opacity-0 translate-y-4 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-500 ease-out"
|
|
83
|
+
>
|
|
84
|
+
<Edit2 size={16} className="text-primary" />
|
|
85
|
+
<span className="text-[11px] font-black uppercase tracking-widest">
|
|
86
|
+
Edit Background
|
|
87
|
+
</span>
|
|
88
|
+
</button>
|
|
108
89
|
)}
|
|
109
90
|
</div>
|
|
110
91
|
);
|