@jhits/plugin-content 0.0.15 → 0.0.18
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/files.d.ts +27 -0
- package/dist/api/files.d.ts.map +1 -0
- package/dist/api/files.js +159 -0
- package/dist/api/handler.d.ts +4 -0
- package/dist/api/handler.d.ts.map +1 -1
- package/dist/api/links.d.ts +23 -0
- package/dist/api/links.d.ts.map +1 -0
- package/dist/api/links.js +75 -0
- package/dist/api/router.d.ts +6 -0
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +32 -4
- package/dist/components/DynamicLink.d.ts +28 -0
- package/dist/components/DynamicLink.d.ts.map +1 -0
- package/dist/components/DynamicLink.js +62 -0
- package/dist/components/LinkSettingsModal.d.ts +22 -0
- package/dist/components/LinkSettingsModal.d.ts.map +1 -0
- package/dist/components/LinkSettingsModal.js +172 -0
- package/dist/components/TranslationEditor.d.ts +3 -1
- package/dist/components/TranslationEditor.d.ts.map +1 -1
- package/dist/components/TranslationEditor.js +11 -5
- package/dist/hooks/useLinks.d.ts +23 -0
- package/dist/hooks/useLinks.d.ts.map +1 -0
- package/dist/hooks/useLinks.js +56 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -8
- package/dist/views/LinkManager/LinkManager.d.ts +9 -0
- package/dist/views/LinkManager/LinkManager.d.ts.map +1 -0
- package/dist/views/LinkManager/LinkManager.js +90 -0
- package/dist/views/MediaManager/MediaManager.d.ts +8 -0
- package/dist/views/MediaManager/MediaManager.d.ts.map +1 -0
- package/dist/views/MediaManager/MediaManager.js +93 -0
- package/dist/views/index.d.ts +10 -0
- package/dist/views/index.d.ts.map +1 -0
- package/dist/views/index.js +22 -0
- package/package.json +1 -1
- package/src/api/files.ts +192 -0
- package/src/api/handler.ts +2 -0
- package/src/api/links.ts +107 -0
- package/src/api/router.ts +37 -8
- package/src/components/DynamicLink.tsx +152 -0
- package/src/components/LinkSettingsModal.tsx +442 -0
- package/src/components/TranslationEditor.tsx +18 -7
- package/src/hooks/useLinks.ts +75 -0
- package/src/index.tsx +40 -9
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Manager View
|
|
3
|
+
* Allows uploading and managing general files (PDFs, docs, etc.)
|
|
4
|
+
*/
|
|
5
|
+
'use client';
|
|
6
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
8
|
+
import { Upload, File, Trash2, Search, Copy, Check, FileText, ExternalLink } from 'lucide-react';
|
|
9
|
+
export function MediaManagerView({ siteId }) {
|
|
10
|
+
const [files, setFiles] = useState([]);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [uploading, setUploading] = useState(false);
|
|
13
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
14
|
+
const [copiedId, setCopiedId] = useState(null);
|
|
15
|
+
const fetchFiles = useCallback(async () => {
|
|
16
|
+
try {
|
|
17
|
+
setLoading(true);
|
|
18
|
+
const res = await fetch(`/api/plugin-content/files?siteId=${siteId}`);
|
|
19
|
+
if (res.ok) {
|
|
20
|
+
const data = await res.json();
|
|
21
|
+
setFiles(data.files || []);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
console.error('Failed to fetch files:', err);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
setLoading(false);
|
|
29
|
+
}
|
|
30
|
+
}, [siteId]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetchFiles();
|
|
33
|
+
}, [fetchFiles]);
|
|
34
|
+
const handleUpload = async (e) => {
|
|
35
|
+
const file = e.target.files?.[0];
|
|
36
|
+
if (!file)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
setUploading(true);
|
|
40
|
+
const formData = new FormData();
|
|
41
|
+
formData.append('file', file);
|
|
42
|
+
formData.append('siteId', siteId);
|
|
43
|
+
const res = await fetch('/api/plugin-content/files/upload', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: formData,
|
|
46
|
+
});
|
|
47
|
+
if (res.ok) {
|
|
48
|
+
fetchFiles();
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
const error = await res.json();
|
|
52
|
+
alert(error.error || 'Upload failed');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
console.error('Upload error:', err);
|
|
57
|
+
alert('An error occurred during upload');
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
setUploading(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const handleDelete = async (id) => {
|
|
64
|
+
if (!confirm('Are you sure you want to delete this file?'))
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`/api/plugin-content/files?siteId=${siteId}&id=${id}`, {
|
|
68
|
+
method: 'DELETE',
|
|
69
|
+
});
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
setFiles(files.filter(f => f.id !== id));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error('Delete error:', err);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const copyToClipboard = (text) => {
|
|
79
|
+
navigator.clipboard.writeText(text);
|
|
80
|
+
setCopiedId(text);
|
|
81
|
+
setTimeout(() => setCopiedId(null), 2000);
|
|
82
|
+
};
|
|
83
|
+
const filteredFiles = files.filter(f => f.filename.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
84
|
+
const formatSize = (bytes) => {
|
|
85
|
+
if (bytes === 0)
|
|
86
|
+
return '0 Bytes';
|
|
87
|
+
const k = 1024;
|
|
88
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
89
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
90
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
91
|
+
};
|
|
92
|
+
return (_jsxs("div", { className: "p-8 bg-white dark:bg-neutral-900 rounded-[2.5rem] h-full flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between mb-8", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-black uppercase tracking-tighter text-neutral-950 dark:text-white", children: "File Library" }), _jsx("p", { className: "text-sm text-neutral-500 dark:text-neutral-400", children: "Upload and manage documents, PDFs, and other assets" })] }), _jsxs("label", { className: `flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:bg-primary/90 transition-all shadow-lg shadow-primary/20 ${uploading ? 'opacity-50 pointer-events-none' : ''}`, children: [_jsx(Upload, { size: 16 }), uploading ? 'Uploading...' : 'Upload File', _jsx("input", { type: "file", className: "hidden", onChange: handleUpload, disabled: uploading })] })] }), _jsxs("div", { className: "relative mb-6", children: [_jsx(Search, { className: "absolute left-4 top-1/2 -translate-y-1/2 text-neutral-400 size-4" }), _jsx("input", { type: "text", value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: "Search files by name...", className: "w-full pl-11 pr-4 py-3 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-2xl text-sm outline-none focus:border-primary transition-all" })] }), _jsx("div", { className: "flex-1 overflow-y-auto custom-scrollbar", children: loading ? (_jsx("div", { className: "flex items-center justify-center py-20", children: _jsx("div", { className: "w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" }) })) : filteredFiles.length === 0 ? (_jsxs("div", { className: "text-center py-20 bg-neutral-50 dark:bg-neutral-800/50 rounded-[2rem] border-2 border-dashed border-neutral-200 dark:border-neutral-700", children: [_jsx(FileText, { className: "mx-auto size-12 text-neutral-300 dark:text-neutral-600 mb-4" }), _jsx("p", { className: "text-neutral-500", children: searchQuery ? 'No files match your search.' : 'No files uploaded yet.' })] })) : (_jsx("div", { className: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6", children: filteredFiles.map((file) => (_jsxs("div", { className: "bg-neutral-50 dark:bg-neutral-800/50 p-5 rounded-[2rem] border border-neutral-200 dark:border-neutral-700 group transition-all hover:shadow-xl hover:border-primary/30", children: [_jsxs("div", { className: "flex items-center gap-4 mb-4", children: [_jsx("div", { className: "p-3 bg-white dark:bg-neutral-900 rounded-2xl shadow-sm group-hover:bg-primary/10 transition-colors", children: _jsx(File, { size: 24, className: "text-primary" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("h3", { className: "font-bold text-sm text-neutral-950 dark:text-white truncate", title: file.filename, children: file.filename }), _jsxs("p", { className: "text-[10px] text-neutral-500 uppercase font-black tracking-tight", children: [formatSize(file.size), " \u2022 ", file.mimeType.split('/')[1].toUpperCase()] })] })] }), _jsxs("div", { className: "flex items-center gap-2 pt-4 border-t border-neutral-200 dark:border-neutral-700", children: [_jsxs("button", { onClick: () => copyToClipboard(file.id), className: "flex-1 flex items-center justify-center gap-2 py-2 bg-white dark:bg-neutral-900 rounded-xl text-[9px] font-black uppercase tracking-widest text-neutral-600 dark:text-neutral-400 hover:text-primary transition-all border border-neutral-200 dark:border-neutral-700", children: [copiedId === file.id ? _jsx(Check, { size: 12 }) : _jsx(Copy, { size: 12 }), copiedId === file.id ? 'Copied' : 'ID'] }), _jsx("a", { href: file.url, target: "_blank", rel: "noopener noreferrer", className: "p-2 bg-white dark:bg-neutral-900 rounded-xl text-neutral-600 dark:text-neutral-400 hover:text-blue-500 transition-all border border-neutral-200 dark:border-neutral-700", title: "View/Download", children: _jsx(ExternalLink, { size: 14 }) }), _jsx("button", { onClick: () => handleDelete(file.id), className: "p-2 bg-white dark:bg-neutral-900 rounded-xl text-neutral-600 dark:text-neutral-400 hover:text-red-500 transition-all border border-neutral-200 dark:border-neutral-700", title: "Delete", children: _jsx(Trash2, { size: 14 }) })] })] }, file.id))) })) })] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset Manager View
|
|
3
|
+
* Main entry point for managing links and files
|
|
4
|
+
*/
|
|
5
|
+
export declare function AssetManagerView({ siteId, locale, subPath }: {
|
|
6
|
+
siteId: string;
|
|
7
|
+
locale: string;
|
|
8
|
+
subPath: string[];
|
|
9
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/views/index.tsx"],"names":[],"mappings":"AAAA;;;GAGG;AASH,wBAAgB,gBAAgB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,2CAkElH"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset Manager View
|
|
3
|
+
* Main entry point for managing links and files
|
|
4
|
+
*/
|
|
5
|
+
'use client';
|
|
6
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
7
|
+
import { Link2, FileText, ChevronRight } from 'lucide-react';
|
|
8
|
+
import { LinkManagerView } from './LinkManager/LinkManager';
|
|
9
|
+
import { MediaManagerView } from './MediaManager/MediaManager';
|
|
10
|
+
export function AssetManagerView({ siteId, locale, subPath }) {
|
|
11
|
+
const route = subPath[1] || 'links';
|
|
12
|
+
const navigate = (path) => {
|
|
13
|
+
window.history.pushState(null, '', `/dashboard/content/${path}`);
|
|
14
|
+
// Force re-render by triggering popstate or similar if needed,
|
|
15
|
+
// but for now we'll just handle it via state if we were in a single view
|
|
16
|
+
};
|
|
17
|
+
return (_jsxs("div", { className: "flex h-full w-full overflow-hidden", children: [_jsxs("aside", { className: "w-64 border-r border-dashboard-border bg-dashboard-sidebar flex flex-col p-6", children: [_jsx("h2", { className: "text-[10px] font-black uppercase tracking-[0.2em] text-neutral-400 mb-8", children: "Content Assets" }), _jsxs("div", { className: "space-y-2", children: [_jsxs("button", { onClick: () => window.location.href = '/dashboard/content/links', className: `w-full flex items-center justify-between p-4 rounded-2xl transition-all ${route === 'links'
|
|
18
|
+
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
|
19
|
+
: 'text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`, children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Link2, { size: 18 }), _jsx("span", { className: "text-sm font-bold", children: "Links & Buttons" })] }), _jsx(ChevronRight, { size: 14, className: route === 'links' ? 'opacity-100' : 'opacity-0' })] }), _jsxs("button", { onClick: () => window.location.href = '/dashboard/content/media', className: `w-full flex items-center justify-between p-4 rounded-2xl transition-all ${route === 'media'
|
|
20
|
+
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
|
21
|
+
: 'text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800'}`, children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(FileText, { size: 18 }), _jsx("span", { className: "text-sm font-bold", children: "File Library" })] }), _jsx(ChevronRight, { size: 14, className: route === 'media' ? 'opacity-100' : 'opacity-0' })] })] }), _jsx("div", { className: "mt-auto p-4 bg-dashboard-bg rounded-2xl border border-dashboard-border", children: _jsx("p", { className: "text-[10px] text-neutral-500 leading-relaxed font-medium", children: "Use these assets to make your application buttons and downloads dynamic." }) })] }), _jsx("main", { className: "flex-1 min-w-0 overflow-hidden", children: route === 'links' ? (_jsx(LinkManagerView, { siteId: siteId, locale: locale })) : (_jsx(MediaManagerView, { siteId: siteId })) })] }));
|
|
22
|
+
}
|
package/package.json
CHANGED
package/src/api/files.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files API Handler
|
|
3
|
+
* Handles uploading and managing general files
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { writeFile, mkdir, unlink, readFile } from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { randomBytes } from 'crypto';
|
|
10
|
+
|
|
11
|
+
export interface FilesApiConfig {
|
|
12
|
+
getDb: () => Promise<{ db: () => any }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const uploadsDir = path.join(process.cwd(), 'data', 'uploads');
|
|
16
|
+
|
|
17
|
+
async function ensureUploadsDir() {
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(uploadsDir, { recursive: true });
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// Directory might already exist
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* GET /api/plugin-content/files - List all files
|
|
27
|
+
*/
|
|
28
|
+
export async function GET(req: NextRequest, config: FilesApiConfig): Promise<NextResponse> {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(req.url);
|
|
31
|
+
const siteId = url.searchParams.get('siteId') || 'default';
|
|
32
|
+
|
|
33
|
+
const dbConnection = await config.getDb();
|
|
34
|
+
const db = dbConnection.db();
|
|
35
|
+
const files = db.collection('files');
|
|
36
|
+
|
|
37
|
+
const data = await files.find({ siteId }).sort({ uploadedAt: -1 }).toArray();
|
|
38
|
+
|
|
39
|
+
return NextResponse.json({ files: data });
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
console.error('[FilesAPI] GET error:', err);
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: 'Failed to fetch files', detail: err.message },
|
|
44
|
+
{ status: 500 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* POST /api/plugin-content/files/upload - Upload a file
|
|
51
|
+
*/
|
|
52
|
+
export async function UPLOAD(req: NextRequest, config: FilesApiConfig): Promise<NextResponse> {
|
|
53
|
+
try {
|
|
54
|
+
await ensureUploadsDir();
|
|
55
|
+
|
|
56
|
+
const formData = await req.formData();
|
|
57
|
+
const file = formData.get('file') as File;
|
|
58
|
+
const siteId = (formData.get('siteId') as string) || 'default';
|
|
59
|
+
|
|
60
|
+
if (!file) {
|
|
61
|
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Generate unique filename
|
|
65
|
+
const ext = path.extname(file.name);
|
|
66
|
+
const uniqueId = randomBytes(16).toString('hex');
|
|
67
|
+
const timestamp = Date.now();
|
|
68
|
+
const uniqueFilename = `${timestamp}-${uniqueId}${ext}`;
|
|
69
|
+
const filePath = path.join(uploadsDir, uniqueFilename);
|
|
70
|
+
|
|
71
|
+
// Save file
|
|
72
|
+
const bytes = await file.arrayBuffer();
|
|
73
|
+
const buffer = Buffer.from(bytes);
|
|
74
|
+
await writeFile(filePath, buffer);
|
|
75
|
+
|
|
76
|
+
const fileMetadata = {
|
|
77
|
+
id: uniqueFilename,
|
|
78
|
+
filename: file.name,
|
|
79
|
+
url: `/api/plugin-content/files/download?id=${uniqueFilename}`, // Point to our own download endpoint
|
|
80
|
+
size: file.size,
|
|
81
|
+
mimeType: file.type,
|
|
82
|
+
siteId,
|
|
83
|
+
uploadedAt: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const dbConnection = await config.getDb();
|
|
87
|
+
const db = dbConnection.db();
|
|
88
|
+
const files = db.collection('files');
|
|
89
|
+
|
|
90
|
+
await files.insertOne(fileMetadata);
|
|
91
|
+
|
|
92
|
+
return NextResponse.json({
|
|
93
|
+
success: true,
|
|
94
|
+
file: fileMetadata,
|
|
95
|
+
});
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
console.error('[FilesAPI] UPLOAD error:', err);
|
|
98
|
+
return NextResponse.json(
|
|
99
|
+
{ error: 'Failed to upload file', detail: err.message },
|
|
100
|
+
{ status: 500 }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* GET /api/plugin-content/files/download - Download a file
|
|
107
|
+
*/
|
|
108
|
+
export async function DOWNLOAD(req: NextRequest, config: FilesApiConfig): Promise<NextResponse> {
|
|
109
|
+
try {
|
|
110
|
+
const url = new URL(req.url);
|
|
111
|
+
const id = url.searchParams.get('id');
|
|
112
|
+
const download = url.searchParams.get('download') === 'true';
|
|
113
|
+
|
|
114
|
+
if (!id) {
|
|
115
|
+
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Security: Prevent directory traversal
|
|
119
|
+
const sanitizedFilename = path.basename(id);
|
|
120
|
+
const filePath = path.join(uploadsDir, sanitizedFilename);
|
|
121
|
+
|
|
122
|
+
const fileBuffer = await readFile(filePath);
|
|
123
|
+
|
|
124
|
+
// Determine content type
|
|
125
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
126
|
+
let contentType = 'application/octet-stream';
|
|
127
|
+
if (ext === '.pdf') contentType = 'application/pdf';
|
|
128
|
+
else if (ext === '.png') contentType = 'image/png';
|
|
129
|
+
else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg';
|
|
130
|
+
else if (ext === '.webp') contentType = 'image/webp';
|
|
131
|
+
else if (ext === '.svg') contentType = 'image/svg+xml';
|
|
132
|
+
|
|
133
|
+
const headers: Record<string, string> = {
|
|
134
|
+
'Content-Type': contentType,
|
|
135
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (download) {
|
|
139
|
+
headers['Content-Disposition'] = `attachment; filename="${sanitizedFilename}"`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return new NextResponse(fileBuffer, {
|
|
143
|
+
headers: headers,
|
|
144
|
+
});
|
|
145
|
+
} catch (err: any) {
|
|
146
|
+
console.error('[FilesAPI] DOWNLOAD error:', err);
|
|
147
|
+
return new NextResponse('File not found', { status: 404 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* DELETE /api/plugin-content/files - Delete a file
|
|
153
|
+
*/
|
|
154
|
+
export async function DELETE(req: NextRequest, config: FilesApiConfig): Promise<NextResponse> {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(req.url);
|
|
157
|
+
const siteId = url.searchParams.get('siteId') || 'default';
|
|
158
|
+
const id = url.searchParams.get('id');
|
|
159
|
+
|
|
160
|
+
if (!id) {
|
|
161
|
+
return NextResponse.json({ error: 'ID is required' }, { status: 400 });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const dbConnection = await config.getDb();
|
|
165
|
+
const db = dbConnection.db();
|
|
166
|
+
const files = db.collection('files');
|
|
167
|
+
|
|
168
|
+
const fileDoc = await files.findOne({ siteId, id });
|
|
169
|
+
if (!fileDoc) {
|
|
170
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Delete from filesystem
|
|
174
|
+
const filePath = path.join(uploadsDir, fileDoc.id);
|
|
175
|
+
try {
|
|
176
|
+
await unlink(filePath);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn(`[FilesAPI] File could not be deleted from disk: ${filePath}`, err);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Delete from database
|
|
182
|
+
await files.deleteOne({ siteId, id });
|
|
183
|
+
|
|
184
|
+
return NextResponse.json({ success: true });
|
|
185
|
+
} catch (err: any) {
|
|
186
|
+
console.error('[FilesAPI] DELETE error:', err);
|
|
187
|
+
return NextResponse.json(
|
|
188
|
+
{ error: 'Failed to delete file', detail: err.message },
|
|
189
|
+
{ status: 500 }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/api/handler.ts
CHANGED
|
@@ -14,6 +14,8 @@ import * as path from 'path';
|
|
|
14
14
|
export interface ContentApiConfig {
|
|
15
15
|
/** Directory where locale files are stored (default: 'data/locales') */
|
|
16
16
|
localesDir?: string;
|
|
17
|
+
/** MongoDB client promise */
|
|
18
|
+
getDb?: () => Promise<{ db: () => any }>;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
package/src/api/links.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links API Handler
|
|
3
|
+
* Handles global, localized links and buttons
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
|
|
8
|
+
export interface LinksApiConfig {
|
|
9
|
+
getDb: () => Promise<{ db: () => any }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GET /api/plugin-content/links - List all links
|
|
14
|
+
*/
|
|
15
|
+
export async function GET(req: NextRequest, config: LinksApiConfig): Promise<NextResponse> {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(req.url);
|
|
18
|
+
const siteId = url.searchParams.get('siteId') || 'default';
|
|
19
|
+
|
|
20
|
+
const dbConnection = await config.getDb();
|
|
21
|
+
const db = dbConnection.db();
|
|
22
|
+
const links = db.collection('links');
|
|
23
|
+
|
|
24
|
+
const data = await links.find({ siteId }).toArray();
|
|
25
|
+
|
|
26
|
+
return NextResponse.json({ links: data });
|
|
27
|
+
} catch (err: any) {
|
|
28
|
+
console.error('[LinksAPI] GET error:', err);
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: 'Failed to fetch links', detail: err.message },
|
|
31
|
+
{ status: 500 }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* POST /api/plugin-content/links - Save a link
|
|
38
|
+
*/
|
|
39
|
+
export async function POST(req: NextRequest, config: LinksApiConfig): Promise<NextResponse> {
|
|
40
|
+
try {
|
|
41
|
+
const body = await req.json();
|
|
42
|
+
const { siteId = 'default', link } = body;
|
|
43
|
+
|
|
44
|
+
if (!link || !link.key) {
|
|
45
|
+
return NextResponse.json({ error: 'Link key is required' }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dbConnection = await config.getDb();
|
|
49
|
+
const db = dbConnection.db();
|
|
50
|
+
const links = db.collection('links');
|
|
51
|
+
|
|
52
|
+
// Update or insert
|
|
53
|
+
const updatedLink = {
|
|
54
|
+
...link,
|
|
55
|
+
siteId,
|
|
56
|
+
updatedAt: new Date(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Remove _id from link before update to avoid immutable field error if it's a string
|
|
60
|
+
const { _id, ...linkData } = updatedLink;
|
|
61
|
+
|
|
62
|
+
await links.updateOne(
|
|
63
|
+
{ siteId, key: link.key },
|
|
64
|
+
{ $set: linkData },
|
|
65
|
+
{ upsert: true }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const savedLink = await links.findOne({ siteId, key: link.key });
|
|
69
|
+
|
|
70
|
+
return NextResponse.json({ success: true, link: savedLink });
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
console.error('[LinksAPI] POST error:', err);
|
|
73
|
+
return NextResponse.json(
|
|
74
|
+
{ error: 'Failed to save link', detail: err.message },
|
|
75
|
+
{ status: 500 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* DELETE /api/plugin-content/links - Delete a link
|
|
82
|
+
*/
|
|
83
|
+
export async function DELETE(req: NextRequest, config: LinksApiConfig): Promise<NextResponse> {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(req.url);
|
|
86
|
+
const siteId = url.searchParams.get('siteId') || 'default';
|
|
87
|
+
const key = url.searchParams.get('key');
|
|
88
|
+
|
|
89
|
+
if (!key) {
|
|
90
|
+
return NextResponse.json({ error: 'Key is required' }, { status: 400 });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dbConnection = await config.getDb();
|
|
94
|
+
const db = dbConnection.db();
|
|
95
|
+
const links = db.collection('links');
|
|
96
|
+
|
|
97
|
+
await links.deleteOne({ siteId, key });
|
|
98
|
+
|
|
99
|
+
return NextResponse.json({ success: true });
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
console.error('[LinksAPI] DELETE error:', err);
|
|
102
|
+
return NextResponse.json(
|
|
103
|
+
{ error: 'Failed to delete link', detail: err.message },
|
|
104
|
+
{ status: 500 }
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/api/router.ts
CHANGED
|
@@ -12,8 +12,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
|
12
12
|
import { POST as SaveHandler } from './handler';
|
|
13
13
|
|
|
14
14
|
export interface ContentApiRouterConfig {
|
|
15
|
+
/** MongoDB client promise - should return { db: () => Database } */
|
|
16
|
+
getDb: () => Promise<{ db: () => any }>;
|
|
15
17
|
/** Directory where locale files are stored (default: 'data/locales') */
|
|
16
18
|
localesDir?: string;
|
|
19
|
+
/** Site ID for multi-site setups */
|
|
20
|
+
siteId?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -29,23 +33,48 @@ export async function handleContentApi(
|
|
|
29
33
|
const safePath = Array.isArray(path) ? path : [];
|
|
30
34
|
const route = safePath.length > 0 ? safePath[0] : '';
|
|
31
35
|
|
|
32
|
-
console.log(`[ContentApiRouter] method=${method}, path=${JSON.stringify(safePath)}, route=${route}, url=${req.url}`);
|
|
33
|
-
|
|
34
36
|
try {
|
|
35
37
|
// Route: /api/plugin-content/save
|
|
36
38
|
if (route === 'save') {
|
|
37
39
|
if (method === 'POST') {
|
|
38
|
-
return await SaveHandler(req, config);
|
|
40
|
+
return await SaveHandler(req, { localesDir: config.localesDir });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Route: /api/plugin-content/links
|
|
44
|
+
else if (route === 'links') {
|
|
45
|
+
const linksModule = await import('./links');
|
|
46
|
+
if (method === 'GET') {
|
|
47
|
+
return await linksModule.GET(req, { getDb: config.getDb });
|
|
48
|
+
}
|
|
49
|
+
if (method === 'POST') {
|
|
50
|
+
return await linksModule.POST(req, { getDb: config.getDb });
|
|
51
|
+
}
|
|
52
|
+
if (method === 'DELETE') {
|
|
53
|
+
return await linksModule.DELETE(req, { getDb: config.getDb });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Route: /api/plugin-content/files
|
|
57
|
+
else if (route === 'files') {
|
|
58
|
+
const filesModule = await import('./files');
|
|
59
|
+
const subRoute = safePath.length > 1 ? safePath[1] : '';
|
|
60
|
+
|
|
61
|
+
if (subRoute === 'upload' && method === 'POST') {
|
|
62
|
+
return await filesModule.UPLOAD(req, { getDb: config.getDb });
|
|
63
|
+
}
|
|
64
|
+
if (subRoute === 'download' && method === 'GET') {
|
|
65
|
+
return await filesModule.DOWNLOAD(req, { getDb: config.getDb });
|
|
66
|
+
}
|
|
67
|
+
if (method === 'GET') {
|
|
68
|
+
return await filesModule.GET(req, { getDb: config.getDb });
|
|
69
|
+
}
|
|
70
|
+
if (method === 'DELETE') {
|
|
71
|
+
return await filesModule.DELETE(req, { getDb: config.getDb });
|
|
39
72
|
}
|
|
40
|
-
return NextResponse.json(
|
|
41
|
-
{ error: `Method ${method} not allowed for route: save` },
|
|
42
|
-
{ status: 405 }
|
|
43
|
-
);
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
// Route not found
|
|
47
76
|
return NextResponse.json(
|
|
48
|
-
{ error: `Route not found: ${route || '/'}` },
|
|
77
|
+
{ error: `Route not found: ${route || '/'}${safePath.length > 1 ? '/' + safePath[1] : ''}` },
|
|
49
78
|
{ status: 404 }
|
|
50
79
|
);
|
|
51
80
|
} catch (error: any) {
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic Link Component
|
|
3
|
+
* Localized link with right-click settings for admins
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect } from 'react';
|
|
9
|
+
import { useLinks } from '../hooks/useLinks';
|
|
10
|
+
import { LinkSettingsModal } from './LinkSettingsModal';
|
|
11
|
+
import Link from 'next/link';
|
|
12
|
+
|
|
13
|
+
interface DynamicLinkProps {
|
|
14
|
+
linkKey: string;
|
|
15
|
+
siteId?: string;
|
|
16
|
+
locale: string;
|
|
17
|
+
defaultLabel: string;
|
|
18
|
+
defaultTarget: string;
|
|
19
|
+
defaultType?: 'url' | 'file';
|
|
20
|
+
className?: string;
|
|
21
|
+
isAdmin?: boolean;
|
|
22
|
+
apiBaseUrl?: string;
|
|
23
|
+
/** Whether to use a button element instead of <a>/Link (useful for scroll actions) */
|
|
24
|
+
isButton?: boolean;
|
|
25
|
+
/** Click handler for button type */
|
|
26
|
+
onClick?: (target: string) => void;
|
|
27
|
+
children?: (data: { label: string; target: string; type: 'url' | 'file' }) => React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function DynamicLink({
|
|
31
|
+
linkKey,
|
|
32
|
+
siteId = 'default',
|
|
33
|
+
locale,
|
|
34
|
+
defaultLabel,
|
|
35
|
+
defaultTarget,
|
|
36
|
+
defaultType = 'url',
|
|
37
|
+
className = '',
|
|
38
|
+
isAdmin = false,
|
|
39
|
+
apiBaseUrl,
|
|
40
|
+
isButton = false,
|
|
41
|
+
onClick,
|
|
42
|
+
children
|
|
43
|
+
}: DynamicLinkProps) {
|
|
44
|
+
const { getLink, refetch } = useLinks(siteId, apiBaseUrl);
|
|
45
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
46
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
47
|
+
|
|
48
|
+
const linkData = getLink(linkKey, locale) || {
|
|
49
|
+
label: defaultLabel,
|
|
50
|
+
target: defaultTarget,
|
|
51
|
+
type: defaultType
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleContextMenu = (e: React.MouseEvent) => {
|
|
55
|
+
if (isAdmin) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
setIsModalOpen(true);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleSaveSuccess = () => {
|
|
63
|
+
if (refetch) refetch();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isExternal = linkData.target.startsWith('http') || linkData.type === 'file';
|
|
67
|
+
const isScroll = linkData.target.startsWith('#');
|
|
68
|
+
|
|
69
|
+
// Render content
|
|
70
|
+
const content = children ? children(linkData) : linkData.label;
|
|
71
|
+
|
|
72
|
+
// Use a wrapper that is invisible to layout when not admin
|
|
73
|
+
// When admin, we need relative for the HUD tooltip
|
|
74
|
+
// We also pass through the className to the wrapper if admin to preserve flex/grid item behavior
|
|
75
|
+
const wrapperClass = isAdmin ? `relative ${className}` : 'contents';
|
|
76
|
+
|
|
77
|
+
// When not admin, the className is on the link itself
|
|
78
|
+
// When admin, the wrapper has the main classes, and the link is just a transparent overlay
|
|
79
|
+
const linkClass = isAdmin ? "w-full h-full flex items-center justify-center bg-transparent border-none p-0 m-0 text-inherit font-inherit cursor-pointer outline-none" : className;
|
|
80
|
+
|
|
81
|
+
const renderLink = () => {
|
|
82
|
+
if (isScroll || isButton) {
|
|
83
|
+
return (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={(e) => {
|
|
87
|
+
if (isScroll) {
|
|
88
|
+
const targetId = linkData.target.replace('#', '');
|
|
89
|
+
const element = document.getElementById(targetId);
|
|
90
|
+
if (element) {
|
|
91
|
+
element.scrollIntoView({ behavior: 'smooth' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (onClick) onClick(linkData.target);
|
|
95
|
+
}}
|
|
96
|
+
className={linkClass}
|
|
97
|
+
>
|
|
98
|
+
{content}
|
|
99
|
+
</button>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (isExternal) {
|
|
104
|
+
return (
|
|
105
|
+
<a
|
|
106
|
+
href={linkData.target}
|
|
107
|
+
target="_blank"
|
|
108
|
+
rel="noopener noreferrer"
|
|
109
|
+
className={linkClass}
|
|
110
|
+
>
|
|
111
|
+
{content}
|
|
112
|
+
</a>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Link href={linkData.target as any} className={linkClass}>
|
|
118
|
+
{content}
|
|
119
|
+
</Link>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<div
|
|
126
|
+
onContextMenu={handleContextMenu}
|
|
127
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
128
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
129
|
+
className={wrapperClass}
|
|
130
|
+
>
|
|
131
|
+
{isAdmin && isHovered && (
|
|
132
|
+
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-primary text-white text-[8px] font-black uppercase px-2 py-1 rounded shadow-xl z-[1000] pointer-events-none whitespace-nowrap">
|
|
133
|
+
Right-click to edit link
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
{renderLink()}
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<LinkSettingsModal
|
|
141
|
+
isOpen={isModalOpen}
|
|
142
|
+
onClose={() => setIsModalOpen(false)}
|
|
143
|
+
linkKey={linkKey}
|
|
144
|
+
siteId={siteId}
|
|
145
|
+
locale={locale}
|
|
146
|
+
initialData={linkData}
|
|
147
|
+
onSaveSuccess={handleSaveSuccess}
|
|
148
|
+
apiBaseUrl={apiBaseUrl}
|
|
149
|
+
/>
|
|
150
|
+
</>
|
|
151
|
+
);
|
|
152
|
+
}
|