@jhits/plugin-images 0.0.1
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 +47 -0
- package/src/api/fallback/route.ts +69 -0
- package/src/api/index.ts +10 -0
- package/src/api/list/index.ts +96 -0
- package/src/api/resolve/route.ts +122 -0
- package/src/api/router.ts +85 -0
- package/src/api/upload/index.ts +88 -0
- package/src/api/uploads/[filename]/route.ts +93 -0
- package/src/api-server.ts +11 -0
- package/src/assets/noimagefound.jpg +0 -0
- package/src/components/BackgroundImage.tsx +111 -0
- package/src/components/GlobalImageEditor.tsx +778 -0
- package/src/components/Image.tsx +177 -0
- package/src/components/ImagePicker.tsx +541 -0
- package/src/components/ImagesPluginInit.tsx +31 -0
- package/src/components/index.ts +7 -0
- package/src/config.ts +179 -0
- package/src/index.server.ts +11 -0
- package/src/index.tsx +56 -0
- package/src/init.tsx +58 -0
- package/src/types/index.ts +60 -0
- package/src/utils/fallback.ts +73 -0
- package/src/views/ImageManager.tsx +30 -0
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jhits/plugin-images",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Image management and storage plugin for the JHITS ecosystem",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"main": "./src/index.tsx",
|
|
9
|
+
"types": "./src/index.tsx",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.tsx",
|
|
13
|
+
"default": "./src/index.tsx"
|
|
14
|
+
},
|
|
15
|
+
"./server": {
|
|
16
|
+
"types": "./src/index.server.ts",
|
|
17
|
+
"default": "./src/index.server.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@jhits/plugin-core": "^0.0.1",
|
|
22
|
+
"mongodb": "^7.0.0",
|
|
23
|
+
"lucide-react": "^0.562.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"next": ">=15.0.0",
|
|
27
|
+
"react": ">=18.0.0",
|
|
28
|
+
"react-dom": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@tailwindcss/postcss": "^4",
|
|
32
|
+
"@types/node": "^20.19.27",
|
|
33
|
+
"@types/react": "^19",
|
|
34
|
+
"@types/react-dom": "^19",
|
|
35
|
+
"eslint": "^9",
|
|
36
|
+
"eslint-config-next": "16.1.1",
|
|
37
|
+
"next": "16.1.1",
|
|
38
|
+
"react": "19.2.3",
|
|
39
|
+
"react-dom": "19.2.3",
|
|
40
|
+
"tailwindcss": "^4",
|
|
41
|
+
"typescript": "^5"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src",
|
|
45
|
+
"package.json"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback Image API Route
|
|
3
|
+
* Serves the default "image not found" fallback image
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { readFile } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
export async function GET(request: NextRequest) {
|
|
11
|
+
try {
|
|
12
|
+
// Try multiple possible paths for the fallback image
|
|
13
|
+
const possiblePaths = [
|
|
14
|
+
// Path in workspace root (monorepo development)
|
|
15
|
+
path.join(process.cwd(), 'packages', 'plugin-images', 'src', 'assets', 'noimagefound.jpg'),
|
|
16
|
+
// Path in node_modules (production)
|
|
17
|
+
path.join(process.cwd(), 'node_modules', '@jhits', 'plugin-images', 'src', 'assets', 'noimagefound.jpg'),
|
|
18
|
+
// Fallback to app's public folder if plugin image not found
|
|
19
|
+
path.join(process.cwd(), 'public', 'noimagefound.jpg'),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
let filePath: string | null = null;
|
|
23
|
+
for (const testPath of possiblePaths) {
|
|
24
|
+
try {
|
|
25
|
+
await readFile(testPath);
|
|
26
|
+
filePath = testPath;
|
|
27
|
+
break;
|
|
28
|
+
} catch {
|
|
29
|
+
// Continue to next path
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!filePath) {
|
|
34
|
+
// If no image found, return a simple placeholder SVG
|
|
35
|
+
const svgPlaceholder = `<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
|
36
|
+
<rect width="400" height="300" fill="#f3f4f6"/>
|
|
37
|
+
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="Arial" font-size="16" fill="#9ca3af">Image not found</text>
|
|
38
|
+
</svg>`;
|
|
39
|
+
return new NextResponse(svgPlaceholder, {
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'image/svg+xml',
|
|
42
|
+
'Cache-Control': 'public, max-age=3600',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fileBuffer = await readFile(filePath);
|
|
48
|
+
|
|
49
|
+
return new NextResponse(fileBuffer, {
|
|
50
|
+
headers: {
|
|
51
|
+
'Content-Type': 'image/jpeg',
|
|
52
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
console.error('[FallbackImage] Error serving fallback image:', error);
|
|
57
|
+
// Return SVG placeholder on error
|
|
58
|
+
const svgPlaceholder = `<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
|
59
|
+
<rect width="400" height="300" fill="#f3f4f6"/>
|
|
60
|
+
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="Arial" font-size="16" fill="#9ca3af">Image not found</text>
|
|
61
|
+
</svg>`;
|
|
62
|
+
return new NextResponse(svgPlaceholder, {
|
|
63
|
+
headers: {
|
|
64
|
+
'Content-Type': 'image/svg+xml',
|
|
65
|
+
'Cache-Control': 'public, max-age=3600',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Images API Exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { handleImagesApi } from './router';
|
|
6
|
+
export { GET as GET_LIST } from './list';
|
|
7
|
+
export { POST as POST_UPLOAD } from './upload';
|
|
8
|
+
export { GET as GET_RESOLVE, POST as POST_RESOLVE } from './resolve/route';
|
|
9
|
+
export { GET as GET_UPLOADS, DELETE as DELETE_UPLOADS } from './uploads/[filename]/route';
|
|
10
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image List API Handler
|
|
3
|
+
* GET /api/plugin-images/list - Returns paginated list of uploaded images
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { readdir, stat } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
|
|
11
|
+
|
|
12
|
+
export async function GET(request: NextRequest) {
|
|
13
|
+
try {
|
|
14
|
+
const { searchParams } = new URL(request.url);
|
|
15
|
+
const page = parseInt(searchParams.get('page') || '1');
|
|
16
|
+
const limit = parseInt(searchParams.get('limit') || '20');
|
|
17
|
+
const search = searchParams.get('search') || '';
|
|
18
|
+
|
|
19
|
+
// Read uploads directory
|
|
20
|
+
let files: string[] = [];
|
|
21
|
+
try {
|
|
22
|
+
files = await readdir(uploadsDir);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// Directory doesn't exist yet
|
|
25
|
+
return NextResponse.json({
|
|
26
|
+
images: [],
|
|
27
|
+
total: 0,
|
|
28
|
+
page: 1,
|
|
29
|
+
limit,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Filter image files and get metadata
|
|
34
|
+
const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];
|
|
35
|
+
const imageFiles = files.filter(file => {
|
|
36
|
+
const ext = path.extname(file).toLowerCase();
|
|
37
|
+
return imageExtensions.includes(ext);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Apply search filter if provided
|
|
41
|
+
const filteredFiles = search
|
|
42
|
+
? imageFiles.filter(file => file.toLowerCase().includes(search.toLowerCase()))
|
|
43
|
+
: imageFiles;
|
|
44
|
+
|
|
45
|
+
// Sort by modification time (newest first)
|
|
46
|
+
const filesWithStats = await Promise.all(
|
|
47
|
+
filteredFiles.map(async (filename) => {
|
|
48
|
+
const filePath = path.join(uploadsDir, filename);
|
|
49
|
+
const stats = await stat(filePath);
|
|
50
|
+
return {
|
|
51
|
+
filename,
|
|
52
|
+
mtime: stats.mtime,
|
|
53
|
+
size: stats.size,
|
|
54
|
+
};
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
59
|
+
|
|
60
|
+
// Paginate
|
|
61
|
+
const start = (page - 1) * limit;
|
|
62
|
+
const end = start + limit;
|
|
63
|
+
const paginatedFiles = filesWithStats.slice(start, end);
|
|
64
|
+
|
|
65
|
+
// Build image metadata
|
|
66
|
+
const images = paginatedFiles.map(({ filename, mtime, size }) => {
|
|
67
|
+
const ext = path.extname(filename).toLowerCase();
|
|
68
|
+
const mimeType = ext === '.png' ? 'image/png' :
|
|
69
|
+
ext === '.webp' ? 'image/webp' :
|
|
70
|
+
ext === '.gif' ? 'image/gif' : 'image/jpeg';
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: filename,
|
|
74
|
+
filename,
|
|
75
|
+
url: `/api/uploads/${filename}`,
|
|
76
|
+
size,
|
|
77
|
+
mimeType,
|
|
78
|
+
uploadedAt: mtime.toISOString(),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return NextResponse.json({
|
|
83
|
+
images,
|
|
84
|
+
total: filteredFiles.length,
|
|
85
|
+
page,
|
|
86
|
+
limit,
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('List images error:', error);
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: 'Failed to list images' },
|
|
92
|
+
{ status: 500 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image ID Resolution API Route
|
|
3
|
+
* Resolves semantic image IDs to actual filenames
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
const mappingsPath = path.join(process.cwd(), 'data', 'image-mappings.json');
|
|
11
|
+
|
|
12
|
+
async function ensureMappingsFile() {
|
|
13
|
+
try {
|
|
14
|
+
const dir = path.dirname(mappingsPath);
|
|
15
|
+
await mkdir(dir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
// Check if file exists, if not create empty mappings
|
|
18
|
+
try {
|
|
19
|
+
await readFile(mappingsPath);
|
|
20
|
+
} catch {
|
|
21
|
+
await writeFile(mappingsPath, JSON.stringify({}, null, 2));
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// Ignore errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GET /api/plugin-images/resolve?id=home-about-card
|
|
30
|
+
* Resolves a semantic image ID to the actual filename
|
|
31
|
+
*/
|
|
32
|
+
export async function GET(request: NextRequest) {
|
|
33
|
+
try {
|
|
34
|
+
await ensureMappingsFile();
|
|
35
|
+
|
|
36
|
+
const { searchParams } = new URL(request.url);
|
|
37
|
+
const id = searchParams.get('id');
|
|
38
|
+
|
|
39
|
+
if (!id) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: 'Missing id parameter' },
|
|
42
|
+
{ status: 400 }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mappings = JSON.parse(await readFile(mappingsPath, 'utf-8'));
|
|
47
|
+
const mapping = mappings[id];
|
|
48
|
+
|
|
49
|
+
if (!mapping) {
|
|
50
|
+
return NextResponse.json(
|
|
51
|
+
{ error: 'Image ID not found', id },
|
|
52
|
+
{ status: 404 }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Support both old format (just filename string) and new format (object)
|
|
57
|
+
const filename = typeof mapping === 'string' ? mapping : mapping.filename;
|
|
58
|
+
const brightness = typeof mapping === 'object' ? (mapping.brightness ?? 100) : 100;
|
|
59
|
+
const blur = typeof mapping === 'object' ? (mapping.blur ?? 0) : 0;
|
|
60
|
+
|
|
61
|
+
return NextResponse.json({
|
|
62
|
+
id,
|
|
63
|
+
filename,
|
|
64
|
+
brightness,
|
|
65
|
+
blur,
|
|
66
|
+
url: `/api/uploads/${encodeURIComponent(filename)}`,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Resolve error:', error);
|
|
70
|
+
return NextResponse.json(
|
|
71
|
+
{ error: 'Failed to resolve image ID' },
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* POST /api/plugin-images/resolve
|
|
79
|
+
* Sets or updates the mapping between an ID and filename
|
|
80
|
+
* Body: { id: string, filename: string }
|
|
81
|
+
*/
|
|
82
|
+
export async function POST(request: NextRequest) {
|
|
83
|
+
try {
|
|
84
|
+
await ensureMappingsFile();
|
|
85
|
+
|
|
86
|
+
const body = await request.json();
|
|
87
|
+
const { id, filename, brightness, blur } = body;
|
|
88
|
+
|
|
89
|
+
if (!id || !filename) {
|
|
90
|
+
return NextResponse.json(
|
|
91
|
+
{ error: 'Missing id or filename' },
|
|
92
|
+
{ status: 400 }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
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
|
+
};
|
|
104
|
+
|
|
105
|
+
await writeFile(mappingsPath, JSON.stringify(mappings, null, 2));
|
|
106
|
+
|
|
107
|
+
return NextResponse.json({
|
|
108
|
+
success: true,
|
|
109
|
+
id,
|
|
110
|
+
filename,
|
|
111
|
+
brightness: brightness ?? 100,
|
|
112
|
+
blur: blur ?? 0,
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error('Set mapping error:', error);
|
|
116
|
+
return NextResponse.json(
|
|
117
|
+
{ error: 'Failed to set image mapping' },
|
|
118
|
+
{ status: 500 }
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin Images API Router
|
|
5
|
+
* Centralized API handler for all images plugin routes
|
|
6
|
+
*
|
|
7
|
+
* This router handles requests to /api/plugin-images/*
|
|
8
|
+
* and routes them to the appropriate handler
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Handle images API requests
|
|
15
|
+
* Routes requests to appropriate handlers based on path
|
|
16
|
+
*/
|
|
17
|
+
export async function handleImagesApi(
|
|
18
|
+
req: NextRequest,
|
|
19
|
+
path: string[]
|
|
20
|
+
): Promise<NextResponse> {
|
|
21
|
+
const method = req.method;
|
|
22
|
+
const route = path[0] || '';
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
// Route: /api/plugin-images/list (list images)
|
|
26
|
+
if (!route || route === 'list') {
|
|
27
|
+
if (method === 'GET') {
|
|
28
|
+
const listModule = await import('./list');
|
|
29
|
+
return await listModule.GET(req);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Route: /api/plugin-images/upload (upload image)
|
|
34
|
+
else if (route === 'upload') {
|
|
35
|
+
if (method === 'POST') {
|
|
36
|
+
const uploadModule = await import('./upload');
|
|
37
|
+
return await uploadModule.POST(req);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Route: /api/plugin-images/resolve (resolve image ID)
|
|
42
|
+
else if (route === 'resolve') {
|
|
43
|
+
if (method === 'GET' || method === 'POST') {
|
|
44
|
+
const resolveModule = await import('./resolve/route');
|
|
45
|
+
if (method === 'GET') {
|
|
46
|
+
return await resolveModule.GET(req);
|
|
47
|
+
}
|
|
48
|
+
return await resolveModule.POST(req);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Route: /api/plugin-images/uploads/[filename] (serve/delete image)
|
|
53
|
+
else if (route === 'uploads' && path[1]) {
|
|
54
|
+
const filename = path[1];
|
|
55
|
+
if (method === 'GET' || method === 'DELETE') {
|
|
56
|
+
const uploadsModule = await import('./uploads/[filename]/route');
|
|
57
|
+
if (method === 'GET') {
|
|
58
|
+
return await uploadsModule.GET(req, { params: Promise.resolve({ filename }) });
|
|
59
|
+
}
|
|
60
|
+
return await uploadsModule.DELETE(req, { params: Promise.resolve({ filename }) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Route: /api/plugin-images/fallback (serve fallback image)
|
|
65
|
+
else if (route === 'fallback') {
|
|
66
|
+
if (method === 'GET') {
|
|
67
|
+
const fallbackModule = await import('./fallback/route');
|
|
68
|
+
return await fallbackModule.GET(req);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Method not allowed
|
|
73
|
+
return NextResponse.json(
|
|
74
|
+
{ error: `Method ${method} not allowed for route: ${route || '/'}` },
|
|
75
|
+
{ status: 405 }
|
|
76
|
+
);
|
|
77
|
+
} catch (error: any) {
|
|
78
|
+
console.error('[ImagesApiRouter] Error:', error);
|
|
79
|
+
return NextResponse.json(
|
|
80
|
+
{ error: error.message || 'Internal server error' },
|
|
81
|
+
{ status: 500 }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Upload API Handler
|
|
3
|
+
* POST /api/plugin-images/upload - Handles image file uploads
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { writeFile, mkdir } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
|
|
11
|
+
// Ensure uploads directory exists
|
|
12
|
+
const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
|
|
13
|
+
|
|
14
|
+
async function ensureUploadsDir() {
|
|
15
|
+
try {
|
|
16
|
+
await mkdir(uploadsDir, { recursive: true });
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// Directory might already exist
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function POST(request: NextRequest) {
|
|
23
|
+
try {
|
|
24
|
+
await ensureUploadsDir();
|
|
25
|
+
|
|
26
|
+
const formData = await request.formData();
|
|
27
|
+
const file = formData.get('file') as File;
|
|
28
|
+
|
|
29
|
+
if (!file) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ success: false, error: 'No file provided' },
|
|
32
|
+
{ status: 400 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validate file type
|
|
37
|
+
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
|
|
38
|
+
if (!allowedTypes.includes(file.type)) {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ success: false, error: 'Invalid file type. Only images are allowed.' },
|
|
41
|
+
{ status: 400 }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate file size (max 10MB)
|
|
46
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
47
|
+
if (file.size > maxSize) {
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{ success: false, error: 'File size exceeds 10MB limit' },
|
|
50
|
+
{ status: 400 }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generate unique filename
|
|
55
|
+
const ext = path.extname(file.name);
|
|
56
|
+
const uniqueId = randomBytes(16).toString('hex');
|
|
57
|
+
const timestamp = Date.now();
|
|
58
|
+
const uniqueFilename = `${timestamp}-${uniqueId}${ext}`;
|
|
59
|
+
const filePath = path.join(uploadsDir, uniqueFilename);
|
|
60
|
+
|
|
61
|
+
// Save file
|
|
62
|
+
const bytes = await file.arrayBuffer();
|
|
63
|
+
const buffer = Buffer.from(bytes);
|
|
64
|
+
await writeFile(filePath, buffer);
|
|
65
|
+
|
|
66
|
+
// Get image dimensions (basic - could use sharp for better handling)
|
|
67
|
+
const imageMetadata = {
|
|
68
|
+
id: uniqueFilename,
|
|
69
|
+
filename: file.name,
|
|
70
|
+
url: `/api/uploads/${uniqueFilename}`,
|
|
71
|
+
size: file.size,
|
|
72
|
+
mimeType: file.type,
|
|
73
|
+
uploadedAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return NextResponse.json({
|
|
77
|
+
success: true,
|
|
78
|
+
image: imageMetadata,
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Upload error:', error);
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ success: false, error: 'Failed to upload image' },
|
|
84
|
+
{ status: 500 }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Uploads API Route
|
|
3
|
+
* Serves uploaded images for the plugin-images system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { readFile, unlink } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
export async function GET(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
{ params }: { params: Promise<{ filename: string }> }
|
|
13
|
+
) {
|
|
14
|
+
const { filename } = await params;
|
|
15
|
+
|
|
16
|
+
// Security: Prevent directory traversal (only allow the filename)
|
|
17
|
+
const sanitizedFilename = path.basename(filename);
|
|
18
|
+
const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
|
|
19
|
+
|
|
20
|
+
// If filename doesn't have an extension, try common image extensions
|
|
21
|
+
let filePath = path.join(uploadsDir, sanitizedFilename);
|
|
22
|
+
const hasExtension = path.extname(sanitizedFilename).length > 0;
|
|
23
|
+
|
|
24
|
+
if (!hasExtension) {
|
|
25
|
+
// Try common image extensions
|
|
26
|
+
const extensions = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg'];
|
|
27
|
+
let found = false;
|
|
28
|
+
for (const ext of extensions) {
|
|
29
|
+
const testPath = filePath + ext;
|
|
30
|
+
try {
|
|
31
|
+
await readFile(testPath);
|
|
32
|
+
filePath = testPath;
|
|
33
|
+
found = true;
|
|
34
|
+
break;
|
|
35
|
+
} catch {
|
|
36
|
+
// Continue to next extension
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!found) {
|
|
40
|
+
// If no extension found, try the original filename
|
|
41
|
+
filePath = path.join(uploadsDir, sanitizedFilename);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const fileBuffer = await readFile(filePath);
|
|
47
|
+
|
|
48
|
+
// Determine content type based on extension
|
|
49
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
50
|
+
let contentType = 'application/octet-stream';
|
|
51
|
+
if (ext === '.png') contentType = 'image/png';
|
|
52
|
+
else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg';
|
|
53
|
+
else if (ext === '.gif') contentType = 'image/gif';
|
|
54
|
+
else if (ext === '.webp') contentType = 'image/webp';
|
|
55
|
+
else if (ext === '.svg') contentType = 'image/svg+xml';
|
|
56
|
+
|
|
57
|
+
return new NextResponse(fileBuffer, {
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': contentType,
|
|
60
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// Don't log errors for missing files - it's expected for some requests
|
|
65
|
+
return new NextResponse('File not found', { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function DELETE(
|
|
70
|
+
request: NextRequest,
|
|
71
|
+
{ params }: { params: Promise<{ filename: string }> }
|
|
72
|
+
) {
|
|
73
|
+
const { filename } = await params;
|
|
74
|
+
|
|
75
|
+
// Security: Prevent directory traversal (only allow the filename)
|
|
76
|
+
const sanitizedFilename = path.basename(filename);
|
|
77
|
+
const filePath = path.join(process.cwd(), 'data', 'uploads', sanitizedFilename);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await unlink(filePath);
|
|
81
|
+
return NextResponse.json({ success: true, message: 'File deleted' });
|
|
82
|
+
} catch (e: any) {
|
|
83
|
+
if (e.code === 'ENOENT') {
|
|
84
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
85
|
+
}
|
|
86
|
+
console.error('File deletion error:', e);
|
|
87
|
+
return NextResponse.json(
|
|
88
|
+
{ error: 'Failed to delete file', detail: e.message },
|
|
89
|
+
{ status: 500 }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Images - Server-Only API Exports
|
|
3
|
+
* This file is only imported in server-side code (API routes)
|
|
4
|
+
*
|
|
5
|
+
* IMPORTANT: This file uses Node.js modules (fs, path, etc.) and should NEVER
|
|
6
|
+
* be imported in client-side code. Only use in server-side API routes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Re-export everything from the API index
|
|
10
|
+
export * from './api';
|
|
11
|
+
|
|
Binary file
|