@jhits/plugin-content 0.0.14 → 0.0.16

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.
Files changed (41) hide show
  1. package/dist/api/files.d.ts +27 -0
  2. package/dist/api/files.d.ts.map +1 -0
  3. package/dist/api/files.js +159 -0
  4. package/dist/api/handler.d.ts +4 -0
  5. package/dist/api/handler.d.ts.map +1 -1
  6. package/dist/api/links.d.ts +23 -0
  7. package/dist/api/links.d.ts.map +1 -0
  8. package/dist/api/links.js +75 -0
  9. package/dist/api/router.d.ts +6 -0
  10. package/dist/api/router.d.ts.map +1 -1
  11. package/dist/api/router.js +32 -3
  12. package/dist/components/DynamicLink.d.ts +28 -0
  13. package/dist/components/DynamicLink.d.ts.map +1 -0
  14. package/dist/components/DynamicLink.js +62 -0
  15. package/dist/components/LinkSettingsModal.d.ts +22 -0
  16. package/dist/components/LinkSettingsModal.d.ts.map +1 -0
  17. package/dist/components/LinkSettingsModal.js +172 -0
  18. package/dist/hooks/useLinks.d.ts +23 -0
  19. package/dist/hooks/useLinks.d.ts.map +1 -0
  20. package/dist/hooks/useLinks.js +56 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -2
  24. package/dist/views/LinkManager/LinkManager.d.ts +9 -0
  25. package/dist/views/LinkManager/LinkManager.d.ts.map +1 -0
  26. package/dist/views/LinkManager/LinkManager.js +90 -0
  27. package/dist/views/MediaManager/MediaManager.d.ts +8 -0
  28. package/dist/views/MediaManager/MediaManager.d.ts.map +1 -0
  29. package/dist/views/MediaManager/MediaManager.js +93 -0
  30. package/dist/views/index.d.ts +10 -0
  31. package/dist/views/index.d.ts.map +1 -0
  32. package/dist/views/index.js +22 -0
  33. package/package.json +2 -2
  34. package/src/api/files.ts +192 -0
  35. package/src/api/handler.ts +2 -0
  36. package/src/api/links.ts +107 -0
  37. package/src/api/router.ts +37 -6
  38. package/src/components/DynamicLink.tsx +152 -0
  39. package/src/components/LinkSettingsModal.tsx +442 -0
  40. package/src/hooks/useLinks.ts +75 -0
  41. package/src/index.tsx +5 -3
@@ -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
+ }
@@ -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
  /**
@@ -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
  /**
@@ -35,17 +39,44 @@ export async function handleContentApi(
35
39
  // Route: /api/plugin-content/save
36
40
  if (route === 'save') {
37
41
  if (method === 'POST') {
38
- return await SaveHandler(req, config);
42
+ return await SaveHandler(req, { localesDir: config.localesDir });
43
+ }
44
+ }
45
+ // Route: /api/plugin-content/links
46
+ else if (route === 'links') {
47
+ const linksModule = await import('./links');
48
+ if (method === 'GET') {
49
+ return await linksModule.GET(req, { getDb: config.getDb });
50
+ }
51
+ if (method === 'POST') {
52
+ return await linksModule.POST(req, { getDb: config.getDb });
53
+ }
54
+ if (method === 'DELETE') {
55
+ return await linksModule.DELETE(req, { getDb: config.getDb });
56
+ }
57
+ }
58
+ // Route: /api/plugin-content/files
59
+ else if (route === 'files') {
60
+ const filesModule = await import('./files');
61
+ const subRoute = safePath.length > 1 ? safePath[1] : '';
62
+
63
+ if (subRoute === 'upload' && method === 'POST') {
64
+ return await filesModule.UPLOAD(req, { getDb: config.getDb });
65
+ }
66
+ if (subRoute === 'download' && method === 'GET') {
67
+ return await filesModule.DOWNLOAD(req, { getDb: config.getDb });
68
+ }
69
+ if (method === 'GET') {
70
+ return await filesModule.GET(req, { getDb: config.getDb });
71
+ }
72
+ if (method === 'DELETE') {
73
+ return await filesModule.DELETE(req, { getDb: config.getDb });
39
74
  }
40
- return NextResponse.json(
41
- { error: `Method ${method} not allowed for route: save` },
42
- { status: 405 }
43
- );
44
75
  }
45
76
 
46
77
  // Route not found
47
78
  return NextResponse.json(
48
- { error: `Route not found: ${route || '/'}` },
79
+ { error: `Route not found: ${route || '/'}${safePath.length > 1 ? '/' + safePath[1] : ''}` },
49
80
  { status: 404 }
50
81
  );
51
82
  } 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
+ }