@jhits/plugin-content 0.0.15 → 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.
- 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 -3
- 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/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 +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- 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 -6
- package/src/components/DynamicLink.tsx +152 -0
- package/src/components/LinkSettingsModal.tsx +442 -0
- package/src/hooks/useLinks.ts +75 -0
- package/src/index.tsx +5 -3
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Settings Modal
|
|
3
|
+
* Inline editor for dynamic links
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import React, { useState, useEffect } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import { X, Save, Upload, Link2, FileText, Globe, Loader2, Check, Download, Search, FolderOpen, Trash2 } from 'lucide-react';
|
|
11
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
12
|
+
|
|
13
|
+
interface MediaFile {
|
|
14
|
+
id: string;
|
|
15
|
+
filename: string;
|
|
16
|
+
url: string;
|
|
17
|
+
size: number;
|
|
18
|
+
mimeType: string;
|
|
19
|
+
uploadedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface LinkSettingsModalProps {
|
|
23
|
+
isOpen: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
linkKey: string;
|
|
26
|
+
siteId: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
initialData?: {
|
|
29
|
+
label: string;
|
|
30
|
+
target: string;
|
|
31
|
+
type: 'url' | 'file';
|
|
32
|
+
};
|
|
33
|
+
onSaveSuccess?: () => void;
|
|
34
|
+
apiBaseUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function LinkSettingsModal({
|
|
38
|
+
isOpen,
|
|
39
|
+
onClose,
|
|
40
|
+
linkKey,
|
|
41
|
+
siteId,
|
|
42
|
+
locale,
|
|
43
|
+
initialData,
|
|
44
|
+
onSaveSuccess,
|
|
45
|
+
apiBaseUrl = '/api/plugin-content'
|
|
46
|
+
}: LinkSettingsModalProps) {
|
|
47
|
+
const [label, setLabel] = useState(initialData?.label || '');
|
|
48
|
+
const [target, setTarget] = useState(initialData?.target || '');
|
|
49
|
+
const [type, setType] = useState<'url' | 'file'>(initialData?.type || 'url');
|
|
50
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
51
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
52
|
+
const [mounted, setMounted] = useState(false);
|
|
53
|
+
|
|
54
|
+
// File browser state
|
|
55
|
+
const [isBrowsing, setIsBrowsing] = useState(false);
|
|
56
|
+
const [availableFiles, setAvailableFiles] = useState<MediaFile[]>([]);
|
|
57
|
+
const [allLinks, setAllLinks] = useState<any[]>([]); // To check for file usage
|
|
58
|
+
const [fileSearch, setFileSearch] = useState('');
|
|
59
|
+
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
|
60
|
+
|
|
61
|
+
const downloadUrl = React.useMemo(() => {
|
|
62
|
+
if (type !== 'file' || !target) return '';
|
|
63
|
+
return `${apiBaseUrl}/files/download?id=${target}&download=true`;
|
|
64
|
+
}, [type, target, apiBaseUrl]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
setMounted(true);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (isOpen && initialData) {
|
|
72
|
+
setLabel(initialData.label);
|
|
73
|
+
setTarget(initialData.target);
|
|
74
|
+
setType(initialData.type);
|
|
75
|
+
setIsBrowsing(false);
|
|
76
|
+
}
|
|
77
|
+
}, [isOpen, initialData]);
|
|
78
|
+
|
|
79
|
+
const fetchFilesAndLinks = async () => {
|
|
80
|
+
setIsLoadingFiles(true);
|
|
81
|
+
try {
|
|
82
|
+
const [filesRes, linksRes] = await Promise.all([
|
|
83
|
+
fetch(`${apiBaseUrl}/files?siteId=${siteId}`),
|
|
84
|
+
fetch(`${apiBaseUrl}/links?siteId=${siteId}`)
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
if (filesRes.ok) {
|
|
88
|
+
const data = await filesRes.json();
|
|
89
|
+
setAvailableFiles(data.files || []);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (linksRes.ok) {
|
|
93
|
+
const data = await linksRes.json();
|
|
94
|
+
setAllLinks(data.links || []);
|
|
95
|
+
}
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Failed to fetch library data:', err);
|
|
98
|
+
} finally {
|
|
99
|
+
setIsLoadingFiles(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Check if a specific file is in use by any link in any language
|
|
104
|
+
const isFileInUse = (fileId: string) => {
|
|
105
|
+
return allLinks.some(link =>
|
|
106
|
+
Object.values(link.languages || {}).some((lang: any) =>
|
|
107
|
+
lang.type === 'file' && lang.target === fileId
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleDeleteFile = async (e: React.MouseEvent, fileId: string) => {
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
if (isFileInUse(fileId)) {
|
|
115
|
+
alert('This file cannot be deleted because it is currently linked to a button.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!confirm('Are you sure you want to permanently delete this file?')) return;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`${apiBaseUrl}/files?siteId=${siteId}&id=${fileId}`, {
|
|
123
|
+
method: 'DELETE',
|
|
124
|
+
});
|
|
125
|
+
if (res.ok) {
|
|
126
|
+
setAvailableFiles(availableFiles.filter(f => f.id !== fileId));
|
|
127
|
+
if (target === fileId) {
|
|
128
|
+
setTarget('');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error('Delete failed:', err);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const filteredFiles = React.useMemo(() => {
|
|
137
|
+
return availableFiles.filter(f =>
|
|
138
|
+
f.filename.toLowerCase().includes(fileSearch.toLowerCase())
|
|
139
|
+
);
|
|
140
|
+
}, [availableFiles, fileSearch]);
|
|
141
|
+
|
|
142
|
+
const handleSave = async () => {
|
|
143
|
+
setIsSaving(true);
|
|
144
|
+
try {
|
|
145
|
+
// 1. First get existing link data to preserve other languages
|
|
146
|
+
const getRes = await fetch(`${apiBaseUrl}/links?siteId=${siteId}`);
|
|
147
|
+
let existingLink = { key: linkKey, languages: {} };
|
|
148
|
+
|
|
149
|
+
if (getRes.ok) {
|
|
150
|
+
const data = await getRes.json();
|
|
151
|
+
const found = data.links?.find((l: any) => l.key === linkKey);
|
|
152
|
+
if (found) existingLink = found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 2. Update the specific language
|
|
156
|
+
const updatedLink = {
|
|
157
|
+
...existingLink,
|
|
158
|
+
languages: {
|
|
159
|
+
...existingLink.languages,
|
|
160
|
+
[locale]: { label, target, type }
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const res = await fetch(`${apiBaseUrl}/links`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ siteId, link: updatedLink }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (res.ok) {
|
|
171
|
+
if (onSaveSuccess) onSaveSuccess();
|
|
172
|
+
// Notify other components
|
|
173
|
+
window.dispatchEvent(new CustomEvent('link-updated'));
|
|
174
|
+
onClose();
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('Failed to save link settings:', err);
|
|
178
|
+
} finally {
|
|
179
|
+
setIsSaving(false);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
184
|
+
const file = e.target.files?.[0];
|
|
185
|
+
if (!file) return;
|
|
186
|
+
|
|
187
|
+
setIsUploading(true);
|
|
188
|
+
try {
|
|
189
|
+
const formData = new FormData();
|
|
190
|
+
formData.append('file', file);
|
|
191
|
+
formData.append('siteId', siteId);
|
|
192
|
+
|
|
193
|
+
const res = await fetch(`${apiBaseUrl}/files/upload`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
body: formData,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
const data = await res.json();
|
|
200
|
+
setTarget(data.file.id);
|
|
201
|
+
setType('file');
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error('Upload failed:', err);
|
|
205
|
+
} finally {
|
|
206
|
+
setIsUploading(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (!mounted || !isOpen) return null;
|
|
211
|
+
|
|
212
|
+
return createPortal(
|
|
213
|
+
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
|
214
|
+
<motion.div
|
|
215
|
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
216
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
217
|
+
className="bg-white dark:bg-neutral-900 w-full max-w-lg rounded-[2.5rem] shadow-2xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"
|
|
218
|
+
onClick={(e) => e.stopPropagation()}
|
|
219
|
+
>
|
|
220
|
+
<div className="p-8">
|
|
221
|
+
<div className="flex items-center justify-between mb-8">
|
|
222
|
+
<div className="flex items-center gap-3">
|
|
223
|
+
<div className="p-3 bg-primary/10 rounded-2xl">
|
|
224
|
+
<Link2 className="text-primary size-6" />
|
|
225
|
+
</div>
|
|
226
|
+
<div>
|
|
227
|
+
<h3 className="text-xl font-black uppercase tracking-tighter text-neutral-950 dark:text-white">
|
|
228
|
+
Link Settings
|
|
229
|
+
</h3>
|
|
230
|
+
<p className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest">
|
|
231
|
+
Key: {linkKey} • {locale.toUpperCase()}
|
|
232
|
+
</p>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<button onClick={onClose} className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-full transition-colors">
|
|
236
|
+
<X size={20} />
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div className="space-y-6">
|
|
241
|
+
{/* Label Field */}
|
|
242
|
+
<div>
|
|
243
|
+
<label className="text-[10px] font-black uppercase tracking-widest text-neutral-400 block mb-2">
|
|
244
|
+
Display Label
|
|
245
|
+
</label>
|
|
246
|
+
<input
|
|
247
|
+
type="text"
|
|
248
|
+
value={label}
|
|
249
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
250
|
+
className="w-full bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 p-4 rounded-2xl text-sm font-bold outline-none focus:border-primary transition-all"
|
|
251
|
+
placeholder="Button text..."
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Target Type Toggle */}
|
|
256
|
+
<div className="flex gap-2 p-1 bg-neutral-100 dark:bg-neutral-800 rounded-2xl">
|
|
257
|
+
<button
|
|
258
|
+
onClick={() => setType('url')}
|
|
259
|
+
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${
|
|
260
|
+
type === 'url' ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary' : 'text-neutral-500'
|
|
261
|
+
}`}
|
|
262
|
+
>
|
|
263
|
+
<Globe size={14} />
|
|
264
|
+
URL / Route
|
|
265
|
+
</button>
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => setType('file')}
|
|
268
|
+
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all ${
|
|
269
|
+
type === 'file' ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary' : 'text-neutral-500'
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
<FileText size={14} />
|
|
273
|
+
Download File
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* Target Field */}
|
|
278
|
+
<div>
|
|
279
|
+
<label className="text-[10px] font-black uppercase tracking-widest text-neutral-400 block mb-2">
|
|
280
|
+
{type === 'url' ? 'Destination URL' : 'File Identifier'}
|
|
281
|
+
</label>
|
|
282
|
+
<div className="relative">
|
|
283
|
+
<input
|
|
284
|
+
type="text"
|
|
285
|
+
value={target}
|
|
286
|
+
onChange={(e) => setTarget(e.target.value)}
|
|
287
|
+
className="w-full bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 p-4 rounded-2xl text-sm font-bold outline-none focus:border-primary transition-all"
|
|
288
|
+
placeholder={type === 'url' ? "/about or https://..." : "Upload or select a file..."}
|
|
289
|
+
/>
|
|
290
|
+
{type === 'file' && target && (
|
|
291
|
+
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
|
292
|
+
<a
|
|
293
|
+
href={downloadUrl}
|
|
294
|
+
target="_blank"
|
|
295
|
+
rel="noopener noreferrer"
|
|
296
|
+
className="p-1.5 bg-white dark:bg-neutral-700 rounded-lg text-neutral-500 hover:text-primary transition-all shadow-sm"
|
|
297
|
+
title="Download File"
|
|
298
|
+
>
|
|
299
|
+
<Download size={14} />
|
|
300
|
+
</a>
|
|
301
|
+
<div className="text-green-500">
|
|
302
|
+
<Check size={16} />
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* File Options (Conditional) */}
|
|
310
|
+
{type === 'file' && (
|
|
311
|
+
<div className="space-y-4">
|
|
312
|
+
<div className="flex gap-2 p-1 bg-neutral-100 dark:bg-neutral-800 rounded-xl">
|
|
313
|
+
<button
|
|
314
|
+
onClick={() => setIsBrowsing(false)}
|
|
315
|
+
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all ${
|
|
316
|
+
!isBrowsing ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary' : 'text-neutral-500'
|
|
317
|
+
}`}
|
|
318
|
+
>
|
|
319
|
+
<Upload size={12} />
|
|
320
|
+
Upload New
|
|
321
|
+
</button>
|
|
322
|
+
<button
|
|
323
|
+
onClick={() => {
|
|
324
|
+
setIsBrowsing(true);
|
|
325
|
+
fetchFilesAndLinks();
|
|
326
|
+
}}
|
|
327
|
+
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all ${
|
|
328
|
+
isBrowsing ? 'bg-white dark:bg-neutral-700 shadow-sm text-primary' : 'text-neutral-500'
|
|
329
|
+
}`}
|
|
330
|
+
>
|
|
331
|
+
<FolderOpen size={12} />
|
|
332
|
+
Browse Library
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{isBrowsing ? (
|
|
337
|
+
<div className="bg-neutral-50 dark:bg-neutral-800/50 rounded-2xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
|
338
|
+
<div className="p-3 border-b border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900">
|
|
339
|
+
<div className="relative">
|
|
340
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 size-3" />
|
|
341
|
+
<input
|
|
342
|
+
type="text"
|
|
343
|
+
value={fileSearch}
|
|
344
|
+
onChange={(e) => setFileSearch(e.target.value)}
|
|
345
|
+
placeholder="Search files..."
|
|
346
|
+
className="w-full pl-9 pr-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-xs outline-none focus:border-primary transition-all"
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
<div className="max-h-[200px] overflow-y-auto p-2 space-y-1 custom-scrollbar">
|
|
351
|
+
{isLoadingFiles ? (
|
|
352
|
+
<div className="flex justify-center py-8">
|
|
353
|
+
<Loader2 className="animate-spin text-primary size-6" />
|
|
354
|
+
</div>
|
|
355
|
+
) : filteredFiles.length === 0 ? (
|
|
356
|
+
<p className="text-center py-8 text-[10px] text-neutral-500 uppercase font-bold">No files found</p>
|
|
357
|
+
) : (
|
|
358
|
+
filteredFiles.map(file => {
|
|
359
|
+
const inUse = isFileInUse(file.id);
|
|
360
|
+
return (
|
|
361
|
+
<div key={file.id} className="flex items-center gap-1 group/item">
|
|
362
|
+
<button
|
|
363
|
+
onClick={() => setTarget(file.id)}
|
|
364
|
+
className={`flex-1 flex items-center gap-3 p-3 rounded-xl transition-all text-left ${
|
|
365
|
+
target === file.id
|
|
366
|
+
? 'bg-primary/10 border border-primary/20'
|
|
367
|
+
: 'hover:bg-white dark:hover:bg-neutral-800 border border-transparent'
|
|
368
|
+
}`}
|
|
369
|
+
>
|
|
370
|
+
<div className="p-2 bg-white dark:bg-neutral-900 rounded-lg shadow-sm">
|
|
371
|
+
<FileText size={14} className="text-primary" />
|
|
372
|
+
</div>
|
|
373
|
+
<div className="min-w-0 flex-1">
|
|
374
|
+
<p className="text-xs font-bold text-neutral-950 dark:text-white truncate">{file.filename}</p>
|
|
375
|
+
<p className="text-[9px] text-neutral-500 uppercase font-black">{file.mimeType.split('/')[1]}</p>
|
|
376
|
+
</div>
|
|
377
|
+
{target === file.id && <Check size={14} className="text-primary" />}
|
|
378
|
+
</button>
|
|
379
|
+
|
|
380
|
+
{!inUse ? (
|
|
381
|
+
<button
|
|
382
|
+
onClick={(e) => handleDeleteFile(e, file.id)}
|
|
383
|
+
className="p-3 text-neutral-400 hover:text-red-500 transition-colors opacity-0 group-hover/item:opacity-100"
|
|
384
|
+
title="Delete file"
|
|
385
|
+
>
|
|
386
|
+
<Trash2 size={14} />
|
|
387
|
+
</button>
|
|
388
|
+
) : (
|
|
389
|
+
<div
|
|
390
|
+
className="p-3 text-neutral-300 cursor-not-allowed"
|
|
391
|
+
title="File is in use and cannot be deleted"
|
|
392
|
+
>
|
|
393
|
+
<Link2 size={14} />
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
})
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
) : (
|
|
403
|
+
<div className="pt-2">
|
|
404
|
+
<label className={`flex items-center justify-center gap-3 w-full p-4 rounded-2xl border-2 border-dashed transition-all cursor-pointer ${
|
|
405
|
+
isUploading
|
|
406
|
+
? 'border-neutral-300 bg-neutral-50 opacity-50 pointer-events-none'
|
|
407
|
+
: 'border-neutral-200 hover:border-primary hover:bg-primary/5'
|
|
408
|
+
}`}>
|
|
409
|
+
{isUploading ? <Loader2 className="animate-spin text-primary" /> : <Upload className="text-primary" />}
|
|
410
|
+
<span className="text-[10px] font-black uppercase tracking-widest text-neutral-600">
|
|
411
|
+
{isUploading ? 'Uploading...' : 'Upload PDF or Document'}
|
|
412
|
+
</span>
|
|
413
|
+
<input type="file" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
|
|
414
|
+
</label>
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="mt-10 flex gap-3">
|
|
422
|
+
<button
|
|
423
|
+
onClick={onClose}
|
|
424
|
+
className="flex-1 px-6 py-4 rounded-full border-2 border-neutral-200 dark:border-neutral-700 text-[10px] font-black uppercase tracking-widest hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-all"
|
|
425
|
+
>
|
|
426
|
+
Cancel
|
|
427
|
+
</button>
|
|
428
|
+
<button
|
|
429
|
+
onClick={handleSave}
|
|
430
|
+
disabled={isSaving || !label}
|
|
431
|
+
className="flex-1 flex items-center justify-center gap-2 px-6 py-4 bg-primary text-white rounded-full text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
432
|
+
>
|
|
433
|
+
{isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
|
434
|
+
{isSaving ? 'Saving...' : 'Save Settings'}
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</motion.div>
|
|
439
|
+
</div>,
|
|
440
|
+
document.body
|
|
441
|
+
);
|
|
442
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLink Hook
|
|
3
|
+
* React hook for fetching dynamic localized links
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
interface LocalizedLink {
|
|
9
|
+
label: string;
|
|
10
|
+
target: string;
|
|
11
|
+
type: 'url' | 'file';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface GlobalLink {
|
|
15
|
+
key: string;
|
|
16
|
+
languages: {
|
|
17
|
+
[locale: string]: LocalizedLink;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useLinks(siteId: string = 'default', apiBaseUrl: string = '/api/plugin-content') {
|
|
22
|
+
const [links, setLinks] = useState<GlobalLink[]>([]);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
const fetchLinks = async () => {
|
|
26
|
+
try {
|
|
27
|
+
setLoading(true);
|
|
28
|
+
const res = await fetch(`${apiBaseUrl}/links?siteId=${siteId}`);
|
|
29
|
+
if (res.ok) {
|
|
30
|
+
const data = await res.json();
|
|
31
|
+
setLinks(data.links || []);
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[useLinks] Failed to fetch links:', err);
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
fetchLinks();
|
|
42
|
+
|
|
43
|
+
// Listen for updates from other components (like the inline editor)
|
|
44
|
+
const handleUpdate = () => fetchLinks();
|
|
45
|
+
window.addEventListener('link-updated', handleUpdate);
|
|
46
|
+
return () => window.removeEventListener('link-updated', handleUpdate);
|
|
47
|
+
}, [siteId, apiBaseUrl]);
|
|
48
|
+
|
|
49
|
+
const getLink = (key: string, locale: string) => {
|
|
50
|
+
const link = links.find(l => l.key === key);
|
|
51
|
+
if (!link) return null;
|
|
52
|
+
|
|
53
|
+
// Try exact locale match, fallback to first available or null
|
|
54
|
+
const localized = link.languages[locale] || Object.values(link.languages)[0] || null;
|
|
55
|
+
if (!localized) return null;
|
|
56
|
+
|
|
57
|
+
// Resolve target if it's a file
|
|
58
|
+
if (localized.type === 'file' && localized.target && !localized.target.startsWith('http') && !localized.target.startsWith('/')) {
|
|
59
|
+
// Get base domain from apiBaseUrl if it exists
|
|
60
|
+
let baseUrl = '';
|
|
61
|
+
if (apiBaseUrl.startsWith('http')) {
|
|
62
|
+
const url = new URL(apiBaseUrl);
|
|
63
|
+
baseUrl = url.origin;
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...localized,
|
|
67
|
+
target: `${baseUrl}/api/plugin-content/files/download?id=${localized.target}&download=true`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return localized;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return { links, loading, getLink, refetch: fetchLinks };
|
|
75
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -40,7 +40,9 @@ function ClientOnly({ children }: { children: React.ReactNode }) {
|
|
|
40
40
|
* Content Plugin Component
|
|
41
41
|
* Renders the translation editor for editing website content
|
|
42
42
|
*/
|
|
43
|
-
export default function ContentPlugin(
|
|
43
|
+
export default function ContentPlugin(props: ContentPluginProps) {
|
|
44
|
+
const { enabled = true, locale, messages } = props;
|
|
45
|
+
|
|
44
46
|
if (!enabled) return null;
|
|
45
47
|
|
|
46
48
|
// If locale and messages are provided as props, use them directly
|
|
@@ -53,7 +55,6 @@ export default function ContentPlugin({ enabled = true, locale, messages }: Cont
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// Otherwise, try to use context (for backward compatibility)
|
|
56
|
-
// This path might have issues with context not being found
|
|
57
58
|
return (
|
|
58
59
|
<ClientOnly>
|
|
59
60
|
<ContentPluginWithIntlFallback />
|
|
@@ -84,6 +85,7 @@ export { default as MultilineText } from './components/MultilineText';
|
|
|
84
85
|
export type { MultilineTextProps } from './components/MultilineText';
|
|
85
86
|
export { default as ParsedText } from './components/ParsedText';
|
|
86
87
|
export type { ParsedTextProps } from './components/ParsedText';
|
|
88
|
+
export { DynamicLink } from './components/DynamicLink';
|
|
87
89
|
export type { TranslationEditorProps } from './components/TranslationEditor';
|
|
88
90
|
|
|
89
91
|
// Export context
|
|
@@ -93,6 +95,7 @@ export type { ParserConfigProviderProps } from './context/ParserConfigContext';
|
|
|
93
95
|
// Export hooks
|
|
94
96
|
export { useParse } from './hooks/useParse';
|
|
95
97
|
export { useLocaleSync } from './hooks/useLocaleSync';
|
|
98
|
+
export { useLinks } from './hooks/useLinks';
|
|
96
99
|
|
|
97
100
|
// Export utilities
|
|
98
101
|
// Note: parse() is a client-only function. Use ParsedText component in server components.
|
|
@@ -104,4 +107,3 @@ export type { ParserConfig, FormatStyle } from './utils/parser-config';
|
|
|
104
107
|
|
|
105
108
|
// Note: API handlers are server-only and exported from ./index.ts (server entry point)
|
|
106
109
|
// They are NOT exported here to prevent client/server context mixing
|
|
107
|
-
|