@fydemy/cms 1.0.2 → 1.0.3
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/admin.template.tsx +661 -0
- package/dist/bin.js +89 -12
- package/dist/bin.js.map +1 -1
- package/dist/bin.mjs +89 -12
- package/dist/bin.mjs.map +1 -1
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +56 -0
- package/dist/components/ui/card.tsx +86 -0
- package/dist/components/ui/input.tsx +25 -0
- package/dist/components/ui/label.tsx +24 -0
- package/dist/components/ui/textarea.tsx +24 -0
- package/dist/components.json +16 -0
- package/dist/index.js +89 -12
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +89 -12
- package/dist/index.mjs.map +1 -1
- package/dist/lib/utils.ts +6 -0
- package/dist/login.template.tsx +126 -0
- package/package.json +4 -1
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
10
|
+
import { Badge } from "@/components/ui/badge";
|
|
11
|
+
|
|
12
|
+
//Types
|
|
13
|
+
interface FileEntry {
|
|
14
|
+
path: string;
|
|
15
|
+
name: string;
|
|
16
|
+
type: "file" | "directory";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type FieldType = "text" | "date" | "number" | "markdown" | "image";
|
|
20
|
+
|
|
21
|
+
interface FieldMeta {
|
|
22
|
+
type: FieldType;
|
|
23
|
+
value: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function AdminDashboard() {
|
|
27
|
+
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
28
|
+
const [currentPath, setCurrentPath] = useState("");
|
|
29
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
30
|
+
const [fields, setFields] = useState<Record<string, FieldMeta>>({});
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const [message, setMessage] = useState("");
|
|
33
|
+
const [newFileName, setNewFileName] = useState("");
|
|
34
|
+
const [uploading, setUploading] = useState(false);
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
loadDirectory(currentPath);
|
|
39
|
+
}, [currentPath]);
|
|
40
|
+
|
|
41
|
+
const loadDirectory = async (path: string) => {
|
|
42
|
+
try {
|
|
43
|
+
const url = path ? `/api/cms/list/${path}` : "/api/cms/list";
|
|
44
|
+
const response = await fetch(url);
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
setEntries(data.entries || []);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("Failed to load directory:", error);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const loadFile = async (filePath: string) => {
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(`/api/cms/content/${filePath}`);
|
|
55
|
+
const data = await response.json();
|
|
56
|
+
setSelectedFile(filePath);
|
|
57
|
+
|
|
58
|
+
const loadedFields: Record<string, FieldMeta> = {};
|
|
59
|
+
|
|
60
|
+
if (data.content !== undefined) {
|
|
61
|
+
loadedFields["content"] = { type: "markdown", value: data.content };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
Object.entries(data.data || {}).forEach(([key, value]) => {
|
|
65
|
+
loadedFields[key] = {
|
|
66
|
+
type: detectFieldType(value as any),
|
|
67
|
+
value,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
setFields(loadedFields);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
setMessage("Failed to load file");
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const detectFieldType = (value: any): FieldType => {
|
|
78
|
+
if (typeof value === "number") return "number";
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
if (value.match(/^\d{4}-\d{2}-\d{2}/)) return "date";
|
|
81
|
+
if (value.startsWith("/uploads/") || value.startsWith("http"))
|
|
82
|
+
return "image";
|
|
83
|
+
if (value.length > 100) return "markdown";
|
|
84
|
+
}
|
|
85
|
+
return "text";
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const saveFile = async () => {
|
|
89
|
+
if (!selectedFile) return;
|
|
90
|
+
|
|
91
|
+
setLoading(true);
|
|
92
|
+
setMessage("");
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const frontmatter: Record<string, any> = {};
|
|
96
|
+
let content = "";
|
|
97
|
+
|
|
98
|
+
Object.entries(fields).forEach(([key, field]) => {
|
|
99
|
+
if (key === "content") {
|
|
100
|
+
content = field.value;
|
|
101
|
+
} else {
|
|
102
|
+
frontmatter[key] = field.value;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const response = await fetch(`/api/cms/content/${selectedFile}`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ data: frontmatter, content }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (response.ok) {
|
|
113
|
+
setMessage("✅ File saved successfully!");
|
|
114
|
+
setTimeout(() => setMessage(""), 3000);
|
|
115
|
+
} else {
|
|
116
|
+
const errorData = await response.json().catch(() => ({}));
|
|
117
|
+
setMessage(
|
|
118
|
+
`❌ Failed to save file: ${errorData.error || response.statusText}`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
setMessage(
|
|
123
|
+
`❌ Error saving file: ${
|
|
124
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
125
|
+
}`
|
|
126
|
+
);
|
|
127
|
+
} finally {
|
|
128
|
+
setLoading(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const createFile = async () => {
|
|
133
|
+
if (!newFileName) return;
|
|
134
|
+
|
|
135
|
+
const fileName = newFileName.endsWith(".md")
|
|
136
|
+
? newFileName
|
|
137
|
+
: `${newFileName}.md`;
|
|
138
|
+
const fullPath = currentPath ? `${currentPath}/${fileName}` : fileName;
|
|
139
|
+
|
|
140
|
+
setLoading(true);
|
|
141
|
+
setMessage("");
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetch(`/api/cms/content/${fullPath}`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
data: {},
|
|
149
|
+
content: "",
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (response.ok) {
|
|
154
|
+
setMessage("✅ File created!");
|
|
155
|
+
setNewFileName("");
|
|
156
|
+
loadDirectory(currentPath);
|
|
157
|
+
loadFile(fullPath);
|
|
158
|
+
} else {
|
|
159
|
+
setMessage("❌ Failed to create file");
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
setMessage("❌ Error creating file");
|
|
163
|
+
} finally {
|
|
164
|
+
setLoading(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const duplicateFile = async () => {
|
|
169
|
+
if (!selectedFile) return;
|
|
170
|
+
|
|
171
|
+
const newName = prompt("Enter new filename (without .md extension):");
|
|
172
|
+
if (!newName) return;
|
|
173
|
+
|
|
174
|
+
const duplicateName = currentPath
|
|
175
|
+
? `${currentPath}/${newName}.md`
|
|
176
|
+
: `${newName}.md`;
|
|
177
|
+
|
|
178
|
+
setLoading(true);
|
|
179
|
+
setMessage("");
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const frontmatter: Record<string, any> = {};
|
|
183
|
+
let content = "";
|
|
184
|
+
|
|
185
|
+
Object.entries(fields).forEach(([key, field]) => {
|
|
186
|
+
if (key === "content") {
|
|
187
|
+
content = field.value;
|
|
188
|
+
} else {
|
|
189
|
+
frontmatter[key] = field.value;
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const response = await fetch(`/api/cms/content/${duplicateName}`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "Content-Type": "application/json" },
|
|
196
|
+
body: JSON.stringify({ data: frontmatter, content }),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (response.ok) {
|
|
200
|
+
setMessage("✅ File duplicated!");
|
|
201
|
+
loadDirectory(currentPath);
|
|
202
|
+
loadFile(duplicateName);
|
|
203
|
+
} else {
|
|
204
|
+
const errorData = await response.json().catch(() => ({}));
|
|
205
|
+
setMessage(
|
|
206
|
+
`❌ Failed to duplicate file: ${errorData.error || "Unknown error"}`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
setMessage("❌ Error duplicating file");
|
|
211
|
+
} finally {
|
|
212
|
+
setLoading(false);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const deleteFile = async (filePath: string) => {
|
|
217
|
+
if (!confirm(`Delete ${filePath}?`)) return;
|
|
218
|
+
|
|
219
|
+
setLoading(true);
|
|
220
|
+
setMessage("");
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const response = await fetch(`/api/cms/content/${filePath}`, {
|
|
224
|
+
method: "DELETE",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (response.ok) {
|
|
228
|
+
setMessage("✅ File deleted!");
|
|
229
|
+
if (selectedFile === filePath) {
|
|
230
|
+
setSelectedFile(null);
|
|
231
|
+
setFields({});
|
|
232
|
+
}
|
|
233
|
+
loadDirectory(currentPath);
|
|
234
|
+
} else {
|
|
235
|
+
setMessage("❌ Failed to delete file");
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
setMessage("❌ Error deleting file");
|
|
239
|
+
} finally {
|
|
240
|
+
setLoading(false);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const navigateToDir = (dirPath: string) => {
|
|
245
|
+
setCurrentPath(dirPath);
|
|
246
|
+
setSelectedFile(null);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const goUp = () => {
|
|
250
|
+
const parts = currentPath.split("/");
|
|
251
|
+
parts.pop();
|
|
252
|
+
setCurrentPath(parts.join("/"));
|
|
253
|
+
setSelectedFile(null);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const handleLogout = async () => {
|
|
257
|
+
await fetch("/api/cms/logout", { method: "POST" });
|
|
258
|
+
router.push("/admin/login");
|
|
259
|
+
router.refresh();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const updateField = (key: string, value: any) => {
|
|
263
|
+
setFields((prev) => ({
|
|
264
|
+
...prev,
|
|
265
|
+
[key]: { ...prev[key], value },
|
|
266
|
+
}));
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const addField = () => {
|
|
270
|
+
const fieldName = prompt("Enter field name:");
|
|
271
|
+
if (!fieldName || fields[fieldName]) return;
|
|
272
|
+
|
|
273
|
+
const fieldType = prompt(
|
|
274
|
+
"Enter field type (text/date/number/markdown/image):",
|
|
275
|
+
"text"
|
|
276
|
+
) as FieldType;
|
|
277
|
+
const validTypes: FieldType[] = [
|
|
278
|
+
"text",
|
|
279
|
+
"date",
|
|
280
|
+
"number",
|
|
281
|
+
"markdown",
|
|
282
|
+
"image",
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
if (!validTypes.includes(fieldType)) {
|
|
286
|
+
alert("Invalid field type! Use: text, date, number, markdown, or image");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const defaultValue =
|
|
291
|
+
fieldType === "number"
|
|
292
|
+
? 0
|
|
293
|
+
: fieldType === "date"
|
|
294
|
+
? new Date().toISOString().split("T")[0]
|
|
295
|
+
: "";
|
|
296
|
+
|
|
297
|
+
setFields((prev) => ({
|
|
298
|
+
...prev,
|
|
299
|
+
[fieldName]: { type: fieldType, value: defaultValue },
|
|
300
|
+
}));
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const removeField = (key: string) => {
|
|
304
|
+
const newFields = { ...fields };
|
|
305
|
+
delete newFields[key];
|
|
306
|
+
setFields(newFields);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const handleImageUpload = async (
|
|
310
|
+
key: string,
|
|
311
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
312
|
+
) => {
|
|
313
|
+
const file = event.target.files?.[0];
|
|
314
|
+
if (!file) return;
|
|
315
|
+
|
|
316
|
+
setUploading(true);
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const formData = new FormData();
|
|
320
|
+
formData.append("file", file);
|
|
321
|
+
|
|
322
|
+
const response = await fetch("/api/cms/upload", {
|
|
323
|
+
method: "POST",
|
|
324
|
+
body: formData,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const data = await response.json();
|
|
328
|
+
|
|
329
|
+
if (response.ok) {
|
|
330
|
+
updateField(key, data.url);
|
|
331
|
+
setMessage(`✅ Image uploaded!`);
|
|
332
|
+
setTimeout(() => setMessage(""), 3000);
|
|
333
|
+
} else {
|
|
334
|
+
setMessage("❌ Failed to upload image");
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
setMessage("❌ Error uploading image");
|
|
338
|
+
} finally {
|
|
339
|
+
setUploading(false);
|
|
340
|
+
event.target.value = "";
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const renderField = (key: string, field: FieldMeta) => {
|
|
345
|
+
switch (field.type) {
|
|
346
|
+
case "text":
|
|
347
|
+
return (
|
|
348
|
+
<Input
|
|
349
|
+
id={key}
|
|
350
|
+
type="text"
|
|
351
|
+
value={field.value}
|
|
352
|
+
onChange={(e) => updateField(key, e.target.value)}
|
|
353
|
+
/>
|
|
354
|
+
);
|
|
355
|
+
case "number":
|
|
356
|
+
return (
|
|
357
|
+
<Input
|
|
358
|
+
id={key}
|
|
359
|
+
type="number"
|
|
360
|
+
value={field.value}
|
|
361
|
+
onChange={(e) => updateField(key, parseFloat(e.target.value) || 0)}
|
|
362
|
+
/>
|
|
363
|
+
);
|
|
364
|
+
case "date":
|
|
365
|
+
return (
|
|
366
|
+
<Input
|
|
367
|
+
id={key}
|
|
368
|
+
type="date"
|
|
369
|
+
value={field.value}
|
|
370
|
+
onChange={(e) => updateField(key, e.target.value)}
|
|
371
|
+
/>
|
|
372
|
+
);
|
|
373
|
+
case "markdown":
|
|
374
|
+
return (
|
|
375
|
+
<Textarea
|
|
376
|
+
id={key}
|
|
377
|
+
value={field.value}
|
|
378
|
+
onChange={(e) => updateField(key, e.target.value)}
|
|
379
|
+
className="font-mono"
|
|
380
|
+
style={{ minHeight: key === "content" ? "400px" : "150px" }}
|
|
381
|
+
placeholder={
|
|
382
|
+
key === "content"
|
|
383
|
+
? "Write your markdown content here..."
|
|
384
|
+
: "Markdown text..."
|
|
385
|
+
}
|
|
386
|
+
/>
|
|
387
|
+
);
|
|
388
|
+
case "image":
|
|
389
|
+
return (
|
|
390
|
+
<div className="space-y-2">
|
|
391
|
+
<Input
|
|
392
|
+
id={key}
|
|
393
|
+
type="text"
|
|
394
|
+
value={field.value}
|
|
395
|
+
onChange={(e) => updateField(key, e.target.value)}
|
|
396
|
+
placeholder="/uploads/image.jpg or https://..."
|
|
397
|
+
/>
|
|
398
|
+
<div className="flex items-center gap-4">
|
|
399
|
+
<label className="relative cursor-pointer rounded-md bg-white font-semibold text-primary hover:text-primary/90">
|
|
400
|
+
<span>Upload a file</span>
|
|
401
|
+
<input
|
|
402
|
+
type="file"
|
|
403
|
+
className="sr-only"
|
|
404
|
+
accept="image/*"
|
|
405
|
+
onChange={(e) => handleImageUpload(key, e)}
|
|
406
|
+
disabled={uploading}
|
|
407
|
+
/>
|
|
408
|
+
</label>
|
|
409
|
+
</div>
|
|
410
|
+
{field.value && (
|
|
411
|
+
<div className="mt-2 rounded-lg border p-1 bg-muted w-fit">
|
|
412
|
+
<img
|
|
413
|
+
src={field.value}
|
|
414
|
+
alt={key}
|
|
415
|
+
className="max-w-[200px] h-auto rounded block"
|
|
416
|
+
/>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
default:
|
|
422
|
+
return (
|
|
423
|
+
<Input
|
|
424
|
+
id={key}
|
|
425
|
+
type="text"
|
|
426
|
+
value={field.value}
|
|
427
|
+
onChange={(e) => updateField(key, e.target.value)}
|
|
428
|
+
/>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<div className="min-h-screen bg-gray-50">
|
|
435
|
+
<div className="bg-white shadow">
|
|
436
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
437
|
+
<div className="flex h-16 justify-between items-center">
|
|
438
|
+
<h1 className="text-xl font-bold tracking-tight">
|
|
439
|
+
📝 Admin Dashboard
|
|
440
|
+
</h1>
|
|
441
|
+
<Button variant="outline" onClick={handleLogout}>
|
|
442
|
+
Logout
|
|
443
|
+
</Button>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
|
449
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
|
450
|
+
{/* Sidebar */}
|
|
451
|
+
<div className="lg:col-span-1">
|
|
452
|
+
<Card>
|
|
453
|
+
<CardHeader className="border-b">
|
|
454
|
+
<CardTitle className="text-base">Files</CardTitle>
|
|
455
|
+
<div className="mt-1 text-sm text-muted-foreground truncate">
|
|
456
|
+
<span
|
|
457
|
+
onClick={() => setCurrentPath("")}
|
|
458
|
+
className="cursor-pointer text-primary hover:text-primary/80 font-medium"
|
|
459
|
+
>
|
|
460
|
+
content
|
|
461
|
+
</span>
|
|
462
|
+
{currentPath && (
|
|
463
|
+
<span className="font-medium"> / {currentPath}</span>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
</CardHeader>
|
|
467
|
+
|
|
468
|
+
<CardContent className="p-4 space-y-4">
|
|
469
|
+
{currentPath && (
|
|
470
|
+
<Button variant="outline" onClick={goUp} className="w-full">
|
|
471
|
+
⬅️ Go Up
|
|
472
|
+
</Button>
|
|
473
|
+
)}
|
|
474
|
+
|
|
475
|
+
<div className="flex gap-2">
|
|
476
|
+
<Input
|
|
477
|
+
type="text"
|
|
478
|
+
placeholder="new-file"
|
|
479
|
+
value={newFileName}
|
|
480
|
+
onChange={(e) => setNewFileName(e.target.value)}
|
|
481
|
+
/>
|
|
482
|
+
<Button
|
|
483
|
+
onClick={createFile}
|
|
484
|
+
disabled={loading || !newFileName}
|
|
485
|
+
size="icon"
|
|
486
|
+
>
|
|
487
|
+
+
|
|
488
|
+
</Button>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<ul role="list" className="space-y-1">
|
|
492
|
+
{entries.length === 0 && (
|
|
493
|
+
<li className="text-center text-sm text-muted-foreground py-4 italic">
|
|
494
|
+
Empty directory
|
|
495
|
+
</li>
|
|
496
|
+
)}
|
|
497
|
+
{entries.map((entry) => (
|
|
498
|
+
<li
|
|
499
|
+
key={entry.path}
|
|
500
|
+
className="flex items-center justify-between group rounded-md p-2 hover:bg-muted"
|
|
501
|
+
>
|
|
502
|
+
{entry.type === "directory" ? (
|
|
503
|
+
<button
|
|
504
|
+
onClick={() => navigateToDir(entry.path)}
|
|
505
|
+
className="flex items-center gap-2 text-sm font-medium text-primary hover:text-primary/80 truncate"
|
|
506
|
+
>
|
|
507
|
+
📁 {entry.name}
|
|
508
|
+
</button>
|
|
509
|
+
) : (
|
|
510
|
+
<button
|
|
511
|
+
onClick={() => loadFile(entry.path)}
|
|
512
|
+
className={`flex items-center gap-2 text-sm truncate flex-1 text-left ${
|
|
513
|
+
selectedFile === entry.path
|
|
514
|
+
? "font-bold"
|
|
515
|
+
: "text-muted-foreground hover:text-foreground"
|
|
516
|
+
}`}
|
|
517
|
+
>
|
|
518
|
+
📄 {entry.name}
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
521
|
+
|
|
522
|
+
{entry.type === "file" && (
|
|
523
|
+
<Button
|
|
524
|
+
variant="ghost"
|
|
525
|
+
size="icon"
|
|
526
|
+
onClick={(e) => {
|
|
527
|
+
e.stopPropagation();
|
|
528
|
+
deleteFile(entry.path);
|
|
529
|
+
}}
|
|
530
|
+
className="opacity-0 group-hover:opacity-100 h-8 w-8 text-destructive hover:text-destructive"
|
|
531
|
+
title="Delete"
|
|
532
|
+
>
|
|
533
|
+
<svg
|
|
534
|
+
className="h-4 w-4"
|
|
535
|
+
fill="none"
|
|
536
|
+
viewBox="0 0 24 24"
|
|
537
|
+
strokeWidth="1.5"
|
|
538
|
+
stroke="currentColor"
|
|
539
|
+
>
|
|
540
|
+
<path
|
|
541
|
+
strokeLinecap="round"
|
|
542
|
+
strokeLinejoin="round"
|
|
543
|
+
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
544
|
+
/>
|
|
545
|
+
</svg>
|
|
546
|
+
</Button>
|
|
547
|
+
)}
|
|
548
|
+
</li>
|
|
549
|
+
))}
|
|
550
|
+
</ul>
|
|
551
|
+
</CardContent>
|
|
552
|
+
</Card>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
{/* Editor */}
|
|
556
|
+
<div className="lg:col-span-3">
|
|
557
|
+
<Card>
|
|
558
|
+
{selectedFile ? (
|
|
559
|
+
<CardContent className="p-6">
|
|
560
|
+
<div className="flex items-center justify-between border-b pb-4 mb-6">
|
|
561
|
+
<h3 className="text-lg font-semibold truncate max-w-lg">
|
|
562
|
+
Editing: {selectedFile}
|
|
563
|
+
</h3>
|
|
564
|
+
<div className="flex gap-2">
|
|
565
|
+
<Button
|
|
566
|
+
variant="outline"
|
|
567
|
+
onClick={duplicateFile}
|
|
568
|
+
disabled={loading}
|
|
569
|
+
>
|
|
570
|
+
Duplicate
|
|
571
|
+
</Button>
|
|
572
|
+
<Button onClick={saveFile} disabled={loading}>
|
|
573
|
+
{loading ? "Saving..." : "Save Changes"}
|
|
574
|
+
</Button>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
{message && (
|
|
579
|
+
<div
|
|
580
|
+
className={`mb-6 rounded-md p-4 text-sm ${
|
|
581
|
+
message.includes("✅")
|
|
582
|
+
? "bg-green-50 text-green-700"
|
|
583
|
+
: "bg-red-50 text-red-700"
|
|
584
|
+
}`}
|
|
585
|
+
>
|
|
586
|
+
{message}
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
|
|
590
|
+
<div className="space-y-6">
|
|
591
|
+
<div className="flex items-center justify-between">
|
|
592
|
+
<h4 className="text-sm font-medium">Fields</h4>
|
|
593
|
+
<Button
|
|
594
|
+
variant="link"
|
|
595
|
+
onClick={addField}
|
|
596
|
+
className="text-sm"
|
|
597
|
+
>
|
|
598
|
+
+ Add Field
|
|
599
|
+
</Button>
|
|
600
|
+
</div>
|
|
601
|
+
|
|
602
|
+
{Object.keys(fields).length === 0 ? (
|
|
603
|
+
<div className="text-center rounded-lg border-2 border-dashed p-12">
|
|
604
|
+
<span className="mt-2 block text-sm font-semibold">
|
|
605
|
+
No fields defined
|
|
606
|
+
</span>
|
|
607
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
608
|
+
Get started by adding a custom field or the main
|
|
609
|
+
content body.
|
|
610
|
+
</p>
|
|
611
|
+
</div>
|
|
612
|
+
) : (
|
|
613
|
+
<div className="space-y-6">
|
|
614
|
+
{Object.entries(fields).map(([key, field]) => (
|
|
615
|
+
<Card key={key}>
|
|
616
|
+
<CardContent className="p-4">
|
|
617
|
+
<div className="flex items-center justify-between mb-4">
|
|
618
|
+
<Label
|
|
619
|
+
htmlFor={key}
|
|
620
|
+
className="text-sm font-bold flex items-center gap-2"
|
|
621
|
+
>
|
|
622
|
+
{key}
|
|
623
|
+
<Badge variant="secondary">
|
|
624
|
+
{field.type}
|
|
625
|
+
</Badge>
|
|
626
|
+
</Label>
|
|
627
|
+
<Button
|
|
628
|
+
variant="ghost"
|
|
629
|
+
size="sm"
|
|
630
|
+
onClick={() => removeField(key)}
|
|
631
|
+
className="text-destructive hover:text-destructive"
|
|
632
|
+
>
|
|
633
|
+
Remove
|
|
634
|
+
</Button>
|
|
635
|
+
</div>
|
|
636
|
+
{renderField(key, field)}
|
|
637
|
+
</CardContent>
|
|
638
|
+
</Card>
|
|
639
|
+
))}
|
|
640
|
+
</div>
|
|
641
|
+
)}
|
|
642
|
+
</div>
|
|
643
|
+
</CardContent>
|
|
644
|
+
) : (
|
|
645
|
+
<CardContent className="text-center py-24">
|
|
646
|
+
<div className="text-5xl mb-4">👈</div>
|
|
647
|
+
<h3 className="mt-2 text-sm font-semibold">
|
|
648
|
+
No file selected
|
|
649
|
+
</h3>
|
|
650
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
651
|
+
Select a file from the sidebar or create a new one.
|
|
652
|
+
</p>
|
|
653
|
+
</CardContent>
|
|
654
|
+
)}
|
|
655
|
+
</Card>
|
|
656
|
+
</div>
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
);
|
|
661
|
+
}
|