@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 CHANGED
@@ -1,39 +1,26 @@
1
1
  {
2
2
  "name": "@jhits/plugin-images",
3
- "version": "0.0.4",
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
- "server-only": "^0.0.1"
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
+ }
@@ -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 = JSON.parse(await readFile(mappingsPath, 'utf-8'));
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
- const mappings = JSON.parse(await readFile(mappingsPath, 'utf-8'));
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
- await writeFile(mappingsPath, JSON.stringify(mappings, null, 2));
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);
@@ -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, Loader2 } from 'lucide-react';
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 && (data.user?.role === 'admin' || data.user?.role === 'dev')) {
32
+ if (data.loggedIn && ['admin', 'dev'].includes(data.user?.role)) {
40
33
  setIsAdmin(true);
41
34
  }
42
35
  } catch (error) {
43
- console.error('Failed to check user role:', 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
- data-background-image-component="true"
61
- data-background-image-id={id}
62
- className={`group relative overflow-hidden w-full ${className}`}
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 IMAGE LAYER (z-0) */}
66
- <div className="absolute inset-0 z-0 pointer-events-none">
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 === 'cover' ? 'cover' : 'contain'}
67
+ objectFit={backgroundSize as any}
73
68
  objectPosition={backgroundPosition}
74
- editable={false} // We handle the button manually below
69
+ editable={false}
75
70
  />
76
71
  </div>
77
72
 
78
- {/* 2. CONTENT LAYER (z-10) */}
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 LAYER (z-50) - Dashboard Theme */}
78
+ {/* 3. ADMIN UI */}
84
79
  {!isLoading && isAdmin && (
85
- <div className="absolute inset-0 z-50 pointer-events-none">
86
- <button
87
- type="button"
88
- onClick={handleEditClick}
89
- className="
90
- pointer-events-auto absolute bottom-8 left-8
91
- flex items-center gap-3 px-6 py-3
92
- bg-neutral-100 dark:bg-neutral-800
93
- text-neutral-900 dark:text-neutral-100
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
  );