@jhits/plugin-images 0.0.13 → 0.0.14
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/dist/api/list/index.d.ts +18 -0
- package/dist/api/list/index.d.ts.map +1 -1
- package/dist/api/list/index.js +121 -20
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +7 -0
- package/dist/api/usage/route.d.ts +23 -0
- package/dist/api/usage/route.d.ts.map +1 -0
- package/dist/api/usage/route.js +238 -0
- package/dist/components/BackgroundImage.d.ts.map +1 -1
- package/dist/components/BackgroundImage.js +5 -17
- package/dist/components/GlobalImageEditor.d.ts.map +1 -1
- package/dist/components/GlobalImageEditor.js +9 -4
- package/dist/components/Image.d.ts +2 -0
- package/dist/components/Image.d.ts.map +1 -1
- package/dist/components/Image.js +22 -9
- package/dist/views/ImageManager.d.ts +1 -1
- package/dist/views/ImageManager.d.ts.map +1 -1
- package/dist/views/ImageManager.js +231 -3
- package/package.json +1 -1
- package/src/api/list/index.ts +147 -22
- package/src/api/router.ts +8 -0
- package/src/api/usage/route.ts +294 -0
- package/src/components/BackgroundImage.tsx +5 -15
- package/src/components/GlobalImageEditor.tsx +9 -4
- package/src/components/Image.tsx +24 -9
- package/src/views/ImageManager.tsx +775 -12
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Usage API Handler
|
|
3
|
+
* GET /api/plugin-images/usage - Returns usage information for all images
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { readdir, stat, readFile } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
|
|
11
|
+
const mappingsPath = path.join(process.cwd(), 'data', 'image-mappings.json');
|
|
12
|
+
|
|
13
|
+
interface ImageUsage {
|
|
14
|
+
imageId: string;
|
|
15
|
+
filename: string;
|
|
16
|
+
usage: Array<{
|
|
17
|
+
plugin: string;
|
|
18
|
+
type: string;
|
|
19
|
+
title: string;
|
|
20
|
+
id: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getMappings(): Promise<Record<string, any>> {
|
|
26
|
+
try {
|
|
27
|
+
const content = await readFile(mappingsPath, 'utf-8');
|
|
28
|
+
return JSON.parse(content);
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function queryMongoDatabase(): Promise<{
|
|
35
|
+
blogs: Array<{ _id: string; slug?: string; title?: string; image?: { id?: string; src?: string }; contentBlocks?: any[] }>;
|
|
36
|
+
newsletters: Array<{ _id: string; slug?: string; title?: string; content?: any[] }>;
|
|
37
|
+
users: Array<{ _id: string; name?: string; email?: string; image?: string }>;
|
|
38
|
+
}> {
|
|
39
|
+
try {
|
|
40
|
+
const { MongoClient } = await import('mongodb');
|
|
41
|
+
const uri = process.env.DATABASE_URL || process.env.MONGODB_URI;
|
|
42
|
+
|
|
43
|
+
if (!uri) {
|
|
44
|
+
return { blogs: [], newsletters: [], users: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const client = new MongoClient(uri);
|
|
48
|
+
await client.connect();
|
|
49
|
+
|
|
50
|
+
const db = client.db();
|
|
51
|
+
|
|
52
|
+
const blogsRaw = await db.collection('blogs')
|
|
53
|
+
.find({}, { projection: { slug: 1, title: 1, image: 1, contentBlocks: 1 } })
|
|
54
|
+
.toArray();
|
|
55
|
+
|
|
56
|
+
const blogs = blogsRaw.map(doc => ({
|
|
57
|
+
_id: doc._id?.toString() || '',
|
|
58
|
+
slug: doc.slug || '',
|
|
59
|
+
title: doc.title || '',
|
|
60
|
+
image: doc.image || {},
|
|
61
|
+
contentBlocks: doc.contentBlocks || [],
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const newslettersRaw = await db.collection('newsletters')
|
|
65
|
+
.find({}, { projection: { slug: 1, title: 1, content: 1 } })
|
|
66
|
+
.toArray();
|
|
67
|
+
|
|
68
|
+
const newsletters = newslettersRaw.map(doc => ({
|
|
69
|
+
_id: doc._id?.toString() || '',
|
|
70
|
+
slug: doc.slug || '',
|
|
71
|
+
title: doc.title || '',
|
|
72
|
+
content: doc.content || [],
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
// Get users with avatars
|
|
76
|
+
const usersRaw = await db.collection('users')
|
|
77
|
+
.find({ image: { $exists: true, $ne: null } })
|
|
78
|
+
.project({ name: 1, email: 1, image: 1 })
|
|
79
|
+
.toArray();
|
|
80
|
+
|
|
81
|
+
const users = usersRaw.map(doc => ({
|
|
82
|
+
_id: doc._id?.toString() || '',
|
|
83
|
+
name: doc.name || '',
|
|
84
|
+
email: doc.email || '',
|
|
85
|
+
image: doc.image || '',
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
await client.close();
|
|
89
|
+
|
|
90
|
+
return { blogs, newsletters, users };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error('Error querying MongoDB:', error);
|
|
93
|
+
return { blogs: [], newsletters: [], users: [] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractImageFilenamesFromContentBlocks(blocks: any[]): string[] {
|
|
98
|
+
const imageFilenames: Set<string> = new Set();
|
|
99
|
+
|
|
100
|
+
function traverse(obj: any) {
|
|
101
|
+
if (!obj || typeof obj !== 'object') return;
|
|
102
|
+
|
|
103
|
+
if (Array.isArray(obj)) {
|
|
104
|
+
obj.forEach(item => traverse(item));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for image object with src or id
|
|
109
|
+
if (obj.src) {
|
|
110
|
+
const filename = obj.src;
|
|
111
|
+
if (filename && !filename.startsWith('http') && !filename.startsWith('data:')) {
|
|
112
|
+
imageFilenames.add(filename);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Check for imageId (used by BotanicsAndYou Image block)
|
|
116
|
+
if (obj.imageId && typeof obj.imageId === 'string' && !obj.imageId.startsWith('http')) {
|
|
117
|
+
imageFilenames.add(obj.imageId);
|
|
118
|
+
}
|
|
119
|
+
if (obj.id && typeof obj.id === 'string' && !obj.id.startsWith('http') && !obj.id.startsWith('block-')) {
|
|
120
|
+
// This might be an image ID - but be careful not to catch block IDs
|
|
121
|
+
const ext = path.extname(obj.id);
|
|
122
|
+
if (ext && ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg'].includes(ext.toLowerCase())) {
|
|
123
|
+
imageFilenames.add(obj.id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check children
|
|
128
|
+
if (obj.children && Array.isArray(obj.children)) {
|
|
129
|
+
obj.children.forEach((child: any) => traverse(child));
|
|
130
|
+
}
|
|
131
|
+
// Check data
|
|
132
|
+
if (obj.data && typeof obj.data === 'object') {
|
|
133
|
+
traverse(obj.data);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
blocks.forEach(block => traverse(block));
|
|
138
|
+
return Array.from(imageFilenames);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function extractImageIdsFromContent(content: any[]): string[] {
|
|
142
|
+
const imageIds: Set<string> = new Set();
|
|
143
|
+
|
|
144
|
+
if (!content || !Array.isArray(content)) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const block of content) {
|
|
149
|
+
if (block.type === 'image' && block.data?.imageId) {
|
|
150
|
+
imageIds.add(block.data.imageId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Array.from(imageIds);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function GET(request: NextRequest) {
|
|
158
|
+
try {
|
|
159
|
+
const { searchParams } = new URL(request.url);
|
|
160
|
+
const filename = searchParams.get('filename');
|
|
161
|
+
|
|
162
|
+
let files: string[] = [];
|
|
163
|
+
try {
|
|
164
|
+
files = await readdir(uploadsDir);
|
|
165
|
+
} catch {
|
|
166
|
+
return NextResponse.json({ images: [] });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
|
170
|
+
const imageFiles = files.filter(file => {
|
|
171
|
+
const ext = path.extname(file).toLowerCase();
|
|
172
|
+
return imageExtensions.includes(ext);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const mappings = await getMappings();
|
|
176
|
+
|
|
177
|
+
const semanticIdToFilename: Record<string, string> = {};
|
|
178
|
+
Object.entries(mappings).forEach(([semanticId, mapping]) => {
|
|
179
|
+
const mappedFilename = typeof mapping === 'string' ? mapping : mapping.filename;
|
|
180
|
+
if (mappedFilename) {
|
|
181
|
+
semanticIdToFilename[semanticId] = mappedFilename;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { blogs, newsletters, users } = await queryMongoDatabase();
|
|
186
|
+
|
|
187
|
+
const images: ImageUsage[] = imageFiles.map((file) => {
|
|
188
|
+
const usage: ImageUsage['usage'] = [];
|
|
189
|
+
|
|
190
|
+
blogs.forEach((blog) => {
|
|
191
|
+
const imageFilenamesInBlog = new Set<string>();
|
|
192
|
+
|
|
193
|
+
// 1. Featured image at root level (blog.image.id or blog.image.src)
|
|
194
|
+
const featuredImage = blog.image;
|
|
195
|
+
if (featuredImage) {
|
|
196
|
+
const featuredId = featuredImage.id || featuredImage.src;
|
|
197
|
+
if (featuredId) {
|
|
198
|
+
const resolved = semanticIdToFilename[featuredId] || featuredId;
|
|
199
|
+
imageFilenamesInBlog.add(resolved);
|
|
200
|
+
|
|
201
|
+
// Check if this file matches
|
|
202
|
+
if (resolved === file || resolved === path.basename(file, path.extname(file))) {
|
|
203
|
+
usage.push({
|
|
204
|
+
plugin: 'blog',
|
|
205
|
+
type: 'featured-image',
|
|
206
|
+
title: blog.title || blog.slug || 'Untitled',
|
|
207
|
+
id: blog.slug || blog._id,
|
|
208
|
+
url: `/dashboard/blog/editor/${blog.slug || blog._id}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 2. Images in contentBlocks
|
|
215
|
+
const contentBlockImages = extractImageFilenamesFromContentBlocks(blog.contentBlocks || []);
|
|
216
|
+
contentBlockImages.forEach(imgId => {
|
|
217
|
+
const resolved = semanticIdToFilename[imgId] || imgId;
|
|
218
|
+
imageFilenamesInBlog.add(resolved);
|
|
219
|
+
|
|
220
|
+
if (resolved === file || resolved === path.basename(file, path.extname(file))) {
|
|
221
|
+
// Avoid duplicate entries for the same blog
|
|
222
|
+
const existing = usage.find(u => u.id === (blog.slug || blog._id) && u.plugin === 'blog');
|
|
223
|
+
if (!existing) {
|
|
224
|
+
usage.push({
|
|
225
|
+
plugin: 'blog',
|
|
226
|
+
type: 'content-image',
|
|
227
|
+
title: blog.title || blog.slug || 'Untitled',
|
|
228
|
+
id: blog.slug || blog._id,
|
|
229
|
+
url: `/dashboard/blog/editor/${blog.slug || blog._id}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
newsletters.forEach((newsletter) => {
|
|
237
|
+
const imageIds = extractImageIdsFromContent(newsletter.content || []);
|
|
238
|
+
imageIds.forEach(imageId => {
|
|
239
|
+
const resolvedFilename = semanticIdToFilename[imageId] || imageId;
|
|
240
|
+
if (resolvedFilename === file || resolvedFilename === path.basename(file, path.extname(file))) {
|
|
241
|
+
const existing = usage.find(u => u.id === (newsletter.slug || newsletter._id) && u.plugin === 'newsletter');
|
|
242
|
+
if (!existing) {
|
|
243
|
+
usage.push({
|
|
244
|
+
plugin: 'newsletter',
|
|
245
|
+
type: 'image-block',
|
|
246
|
+
title: newsletter.title || newsletter.slug || 'Untitled',
|
|
247
|
+
id: newsletter.slug || newsletter._id,
|
|
248
|
+
url: `/dashboard/newsletter/editor/${newsletter.slug || newsletter._id}`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Check user avatars
|
|
256
|
+
users.forEach((user) => {
|
|
257
|
+
if (user.image && user.image.includes('/api/uploads/')) {
|
|
258
|
+
const avatarFilename = user.image.split('/api/uploads/')[1];
|
|
259
|
+
if (avatarFilename && (avatarFilename === file || avatarFilename === path.basename(file, path.extname(file)))) {
|
|
260
|
+
const existing = usage.find(u => u.id === user._id && u.plugin === 'users');
|
|
261
|
+
if (!existing) {
|
|
262
|
+
usage.push({
|
|
263
|
+
plugin: 'users',
|
|
264
|
+
type: 'avatar',
|
|
265
|
+
title: user.name || user.email || 'Unknown User',
|
|
266
|
+
id: user._id,
|
|
267
|
+
url: `/dashboard/users`,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
imageId: file,
|
|
276
|
+
filename: file,
|
|
277
|
+
usage,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (filename) {
|
|
282
|
+
const filtered = images.filter(img => img.filename === filename || img.imageId === filename);
|
|
283
|
+
return NextResponse.json({ images: filtered });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return NextResponse.json({ images });
|
|
287
|
+
} catch (error) {
|
|
288
|
+
console.error('Get image usage error:', error);
|
|
289
|
+
return NextResponse.json(
|
|
290
|
+
{ error: 'Failed to get image usage' },
|
|
291
|
+
{ status: 500 }
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect } from 'react';
|
|
4
|
-
import { Image } from './Image';
|
|
4
|
+
import { Image, checkAuthOnce } from './Image';
|
|
5
5
|
import { Edit2 } from 'lucide-react';
|
|
6
6
|
|
|
7
7
|
export interface BackgroundImageProps {
|
|
@@ -25,20 +25,10 @@ export function BackgroundImage({
|
|
|
25
25
|
const [isLoading, setIsLoading] = useState(true);
|
|
26
26
|
|
|
27
27
|
useEffect(() => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (data.loggedIn && ['admin', 'dev'].includes(data.user?.role)) {
|
|
33
|
-
setIsAdmin(true);
|
|
34
|
-
}
|
|
35
|
-
} catch (error) {
|
|
36
|
-
console.error('Auth error:', error);
|
|
37
|
-
} finally {
|
|
38
|
-
setIsLoading(false);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
checkUser();
|
|
28
|
+
checkAuthOnce().then(isAuth => {
|
|
29
|
+
setIsAdmin(isAuth);
|
|
30
|
+
setIsLoading(false);
|
|
31
|
+
});
|
|
42
32
|
}, []);
|
|
43
33
|
|
|
44
34
|
const handleEditClick = (e: React.MouseEvent) => {
|
|
@@ -17,6 +17,7 @@ import { parseImageData } from './GlobalImageEditor/imageDetection';
|
|
|
17
17
|
import { setupImageHandlers } from './GlobalImageEditor/imageSetup';
|
|
18
18
|
import { saveTransformToAPI, flushPendingSave, getFilename, normalizePosition } from './GlobalImageEditor/saveLogic';
|
|
19
19
|
import { handleImageChange, handleBrightnessChange, handleBlurChange } from './GlobalImageEditor/eventHandlers';
|
|
20
|
+
import { checkAuthOnce } from './Image';
|
|
20
21
|
|
|
21
22
|
export function GlobalImageEditor() {
|
|
22
23
|
// Configuration
|
|
@@ -36,10 +37,14 @@ export function GlobalImageEditor() {
|
|
|
36
37
|
useEffect(() => {
|
|
37
38
|
const checkUser = async () => {
|
|
38
39
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const isAuth = await checkAuthOnce();
|
|
41
|
+
if (isAuth) {
|
|
42
|
+
// Need to fetch again to get user role since checkAuthOnce only returns boolean
|
|
43
|
+
const res = await fetch('/api/me');
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (data.user?.role === 'admin' || data.user?.role === 'dev') {
|
|
46
|
+
setUserRole(data.user.role);
|
|
47
|
+
}
|
|
43
48
|
}
|
|
44
49
|
} catch (error) {
|
|
45
50
|
console.error('Failed to check user role:', error);
|
package/src/components/Image.tsx
CHANGED
|
@@ -6,6 +6,28 @@ import { Edit2, Loader2 } from 'lucide-react';
|
|
|
6
6
|
import { getImageTransform, getImageFilter } from '../utils/transforms';
|
|
7
7
|
import { getFallbackImageUrl } from '../utils/fallback';
|
|
8
8
|
|
|
9
|
+
export let authCache: boolean | null = null;
|
|
10
|
+
let authFetching: Promise<boolean> | null = null;
|
|
11
|
+
|
|
12
|
+
export async function checkAuthOnce(): Promise<boolean> {
|
|
13
|
+
if (authCache !== null) return authCache;
|
|
14
|
+
if (authFetching) return authFetching;
|
|
15
|
+
|
|
16
|
+
authFetching = fetch('/api/me')
|
|
17
|
+
.then(res => res.json())
|
|
18
|
+
.then(data => {
|
|
19
|
+
const isAuth = data.loggedIn && ['admin', 'dev'].includes(data.user?.role);
|
|
20
|
+
authCache = isAuth;
|
|
21
|
+
return isAuth;
|
|
22
|
+
})
|
|
23
|
+
.catch(() => {
|
|
24
|
+
authCache = false;
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return authFetching;
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export interface PluginImageProps {
|
|
10
32
|
id: string;
|
|
11
33
|
alt: string;
|
|
@@ -104,17 +126,10 @@ export function Image({
|
|
|
104
126
|
}
|
|
105
127
|
}, []);
|
|
106
128
|
|
|
107
|
-
// 4. Auth Check (
|
|
129
|
+
// 4. Auth Check (Cached - only fetches once)
|
|
108
130
|
useEffect(() => {
|
|
109
131
|
if (!editable) return;
|
|
110
|
-
|
|
111
|
-
.then(res => res.json())
|
|
112
|
-
.then(data => {
|
|
113
|
-
if (data.loggedIn && ['admin', 'dev'].includes(data.user?.role)) {
|
|
114
|
-
setIsAuthenticated(true);
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
|
-
.catch(() => setIsAuthenticated(false));
|
|
132
|
+
checkAuthOnce().then(setIsAuthenticated);
|
|
118
133
|
}, [editable]);
|
|
119
134
|
|
|
120
135
|
// 5. Stable Event Listener (Prevents closure staleness)
|