@fydemy/cms 1.0.2 → 1.0.4

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.
@@ -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
+ }