@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.
@@ -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
- const checkUser = async () => {
29
- try {
30
- const res = await fetch('/api/me');
31
- const data = await res.json();
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 res = await fetch('/api/me');
40
- const data = await res.json();
41
- if (data.loggedIn && (data.user?.role === 'admin' || data.user?.role === 'dev')) {
42
- setUserRole(data.user.role);
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);
@@ -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 (Single mount only)
129
+ // 4. Auth Check (Cached - only fetches once)
108
130
  useEffect(() => {
109
131
  if (!editable) return;
110
- fetch('/api/me')
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)