create-nextjs-stack 0.1.0
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/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/cli.js +187 -0
- package/package.json +48 -0
- package/templates/admin/.env.example +11 -0
- package/templates/admin/README.md +82 -0
- package/templates/admin/app/(auth)/login/page.tsx +84 -0
- package/templates/admin/app/(dashboard)/[resource]/[id]/page.tsx +45 -0
- package/templates/admin/app/(dashboard)/[resource]/new/page.tsx +32 -0
- package/templates/admin/app/(dashboard)/[resource]/page.tsx +131 -0
- package/templates/admin/app/(dashboard)/categories/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/categories/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/categories/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/clients/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/clients/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/clients/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/dashboard/page.tsx +45 -0
- package/templates/admin/app/(dashboard)/layout.tsx +13 -0
- package/templates/admin/app/(dashboard)/products/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/products/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/products/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/projects/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/projects/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/projects/page.tsx +33 -0
- package/templates/admin/app/(dashboard)/users/[id]/page.tsx +22 -0
- package/templates/admin/app/(dashboard)/users/new/page.tsx +5 -0
- package/templates/admin/app/(dashboard)/users/page.tsx +33 -0
- package/templates/admin/app/actions/resources.ts +46 -0
- package/templates/admin/app/actions/upload.ts +58 -0
- package/templates/admin/app/favicon.ico +0 -0
- package/templates/admin/app/globals.css +23 -0
- package/templates/admin/app/layout.tsx +23 -0
- package/templates/admin/app/page.tsx +5 -0
- package/templates/admin/components/admin/AdminLayoutClient.tsx +22 -0
- package/templates/admin/components/admin/DeleteModal.tsx +90 -0
- package/templates/admin/components/admin/FormLayout.tsx +113 -0
- package/templates/admin/components/admin/ImageUpload.tsx +137 -0
- package/templates/admin/components/admin/ResourceFormClient.tsx +62 -0
- package/templates/admin/components/admin/Sidebar.tsx +74 -0
- package/templates/admin/components/admin/SubmitButton.tsx +34 -0
- package/templates/admin/components/admin/ToastProvider.tsx +8 -0
- package/templates/admin/components/categories/CategoryForm.tsx +24 -0
- package/templates/admin/components/categories/CategoryList.tsx +113 -0
- package/templates/admin/components/clients/ClientForm.tsx +24 -0
- package/templates/admin/components/clients/ClientList.tsx +113 -0
- package/templates/admin/components/products/ProductForm.tsx +24 -0
- package/templates/admin/components/products/ProductList.tsx +117 -0
- package/templates/admin/components/projects/ProjectForm.tsx +24 -0
- package/templates/admin/components/projects/ProjectList.tsx +121 -0
- package/templates/admin/components/users/UserForm.tsx +39 -0
- package/templates/admin/components/users/UserList.tsx +101 -0
- package/templates/admin/config/resources.ts +123 -0
- package/templates/admin/eslint.config.mjs +18 -0
- package/templates/admin/hooks/useResource.ts +86 -0
- package/templates/admin/lib/services/base.service.ts +106 -0
- package/templates/admin/lib/services/categories.service.ts +7 -0
- package/templates/admin/lib/services/clients.service.ts +7 -0
- package/templates/admin/lib/services/index.ts +27 -0
- package/templates/admin/lib/services/products.service.ts +9 -0
- package/templates/admin/lib/services/projects.service.ts +22 -0
- package/templates/admin/lib/services/resource.service.ts +26 -0
- package/templates/admin/lib/services/users.service.ts +9 -0
- package/templates/admin/lib/supabase/client.ts +9 -0
- package/templates/admin/lib/supabase/middleware.ts +57 -0
- package/templates/admin/lib/supabase/server.ts +29 -0
- package/templates/admin/middleware.ts +15 -0
- package/templates/admin/next.config.ts +10 -0
- package/templates/admin/package-lock.json +6768 -0
- package/templates/admin/package.json +33 -0
- package/templates/admin/postcss.config.mjs +7 -0
- package/templates/admin/public/file.svg +1 -0
- package/templates/admin/public/globe.svg +1 -0
- package/templates/admin/public/next.svg +1 -0
- package/templates/admin/public/vercel.svg +1 -0
- package/templates/admin/public/window.svg +1 -0
- package/templates/admin/supabase_mock_data.sql +57 -0
- package/templates/admin/supabase_schema.sql +93 -0
- package/templates/admin/tsconfig.json +34 -0
- package/templates/web/.env.example +21 -0
- package/templates/web/README.md +129 -0
- package/templates/web/components.json +22 -0
- package/templates/web/eslint.config.mjs +25 -0
- package/templates/web/next.config.ts +25 -0
- package/templates/web/package-lock.json +6778 -0
- package/templates/web/package.json +45 -0
- package/templates/web/postcss.config.mjs +5 -0
- package/templates/web/src/app/api/contact/route.ts +181 -0
- package/templates/web/src/app/api/revalidate/route.ts +95 -0
- package/templates/web/src/app/error.tsx +28 -0
- package/templates/web/src/app/globals.css +838 -0
- package/templates/web/src/app/layout.tsx +126 -0
- package/templates/web/src/app/loading.tsx +60 -0
- package/templates/web/src/app/not-found.tsx +68 -0
- package/templates/web/src/app/page.tsx +106 -0
- package/templates/web/src/app/robots.ts +12 -0
- package/templates/web/src/app/sitemap.ts +66 -0
- package/templates/web/src/components/home/StatsGrid.tsx +89 -0
- package/templates/web/src/hooks/useIntersectionObserver.ts +39 -0
- package/templates/web/src/lib/providers/StoreProvider.tsx +12 -0
- package/templates/web/src/lib/seo/index.ts +4 -0
- package/templates/web/src/lib/seo/metadata.ts +103 -0
- package/templates/web/src/lib/seo/seo.config.ts +161 -0
- package/templates/web/src/lib/seo/seo.types.ts +76 -0
- package/templates/web/src/lib/services/categories.service.ts +38 -0
- package/templates/web/src/lib/services/categoryService.ts +251 -0
- package/templates/web/src/lib/services/clientService.ts +132 -0
- package/templates/web/src/lib/services/clients.service.ts +20 -0
- package/templates/web/src/lib/services/productService.ts +261 -0
- package/templates/web/src/lib/services/products.service.ts +38 -0
- package/templates/web/src/lib/services/projectService.ts +234 -0
- package/templates/web/src/lib/services/projects.service.ts +38 -0
- package/templates/web/src/lib/services/users.service.ts +20 -0
- package/templates/web/src/lib/supabase/client.ts +42 -0
- package/templates/web/src/lib/supabase/constants.ts +25 -0
- package/templates/web/src/lib/supabase/server.ts +29 -0
- package/templates/web/src/lib/supabase/types.ts +112 -0
- package/templates/web/src/lib/utils/cache.ts +98 -0
- package/templates/web/src/lib/utils/rate-limiter.ts +102 -0
- package/templates/web/src/store/actions/index.ts +2 -0
- package/templates/web/src/store/index.ts +13 -0
- package/templates/web/src/store/reducers/index.ts +13 -0
- package/templates/web/src/store/types/index.ts +2 -0
- package/templates/web/tsconfig.json +41 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Sidebar from "./Sidebar";
|
|
4
|
+
import ToastProvider from "./ToastProvider";
|
|
5
|
+
|
|
6
|
+
export default function AdminLayoutClient({
|
|
7
|
+
children,
|
|
8
|
+
}: {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="flex min-h-screen bg-gray-100">
|
|
13
|
+
<Sidebar />
|
|
14
|
+
<main className="flex-1 p-8 overflow-y-auto h-screen">
|
|
15
|
+
<div className="max-w-7xl mx-auto">
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
</main>
|
|
19
|
+
<ToastProvider />
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { AlertTriangle, X } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
interface DeleteModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onConfirm: () => void;
|
|
10
|
+
title?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
isLoading?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function DeleteModal({
|
|
16
|
+
isOpen,
|
|
17
|
+
onClose,
|
|
18
|
+
onConfirm,
|
|
19
|
+
title = "Delete Resource",
|
|
20
|
+
description = "Are you sure you want to delete this resource? This action cannot be undone.",
|
|
21
|
+
isLoading = false,
|
|
22
|
+
}: DeleteModalProps) {
|
|
23
|
+
const [show, setShow] = useState(isOpen);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setShow(isOpen);
|
|
27
|
+
}, [isOpen]);
|
|
28
|
+
|
|
29
|
+
if (!isOpen) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
|
33
|
+
{/* Backdrop */}
|
|
34
|
+
<div
|
|
35
|
+
className="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
|
|
36
|
+
onClick={!isLoading ? onClose : undefined}
|
|
37
|
+
/>
|
|
38
|
+
|
|
39
|
+
{/* Modal Panel */}
|
|
40
|
+
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden transform transition-all animate-in fade-in zoom-in-95 duration-200">
|
|
41
|
+
<div className="p-6">
|
|
42
|
+
<div className="flex items-start gap-4">
|
|
43
|
+
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
|
44
|
+
<AlertTriangle className="w-5 h-5 text-red-600" />
|
|
45
|
+
</div>
|
|
46
|
+
<div className="flex-1">
|
|
47
|
+
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
48
|
+
{title}
|
|
49
|
+
</h3>
|
|
50
|
+
<p className="text-sm text-gray-500 leading-relaxed">
|
|
51
|
+
{description}
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
<button
|
|
55
|
+
onClick={onClose}
|
|
56
|
+
disabled={isLoading}
|
|
57
|
+
className="text-gray-400 hover:text-gray-500 transition-colors"
|
|
58
|
+
>
|
|
59
|
+
<X className="w-5 h-5" />
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
|
|
65
|
+
<button
|
|
66
|
+
onClick={onClose}
|
|
67
|
+
disabled={isLoading}
|
|
68
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-200 transition-colors disabled:opacity-50"
|
|
69
|
+
>
|
|
70
|
+
Cancel
|
|
71
|
+
</button>
|
|
72
|
+
<button
|
|
73
|
+
onClick={onConfirm}
|
|
74
|
+
disabled={isLoading}
|
|
75
|
+
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
76
|
+
>
|
|
77
|
+
{isLoading ? (
|
|
78
|
+
<>
|
|
79
|
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
80
|
+
Deleting...
|
|
81
|
+
</>
|
|
82
|
+
) : (
|
|
83
|
+
"Delete"
|
|
84
|
+
)}
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useFormContext } from "react-hook-form";
|
|
4
|
+
import SubmitButton from "./SubmitButton";
|
|
5
|
+
import { getRelationOptions } from "@/app/actions/resources";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import ImageUpload from "./ImageUpload";
|
|
8
|
+
|
|
9
|
+
interface FormLayoutProps {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
fields: any[];
|
|
12
|
+
title: string;
|
|
13
|
+
isSubmitting: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function FormLayout({ fields, title, isSubmitting }: FormLayoutProps) {
|
|
17
|
+
const { register, formState: { errors }, control, watch } = useFormContext();
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
const [relationOptions, setRelationOptions] = useState<Record<string, any[]>>({});
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
// Fetch relation options
|
|
23
|
+
fields.forEach(async (field) => {
|
|
24
|
+
if (field.type === 'select' && field.relation) {
|
|
25
|
+
try {
|
|
26
|
+
const data = await getRelationOptions(
|
|
27
|
+
field.relation.table,
|
|
28
|
+
field.relation.display
|
|
29
|
+
);
|
|
30
|
+
setRelationOptions(prev => ({ ...prev, [field.name]: data }));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('Failed to fetch options', error);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}, [fields]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="bg-white rounded-lg shadow p-6">
|
|
40
|
+
<h2 className="text-xl font-semibold mb-6">{title}</h2>
|
|
41
|
+
<div className="space-y-6">
|
|
42
|
+
{fields.map((field) => (
|
|
43
|
+
<div key={field.name}>
|
|
44
|
+
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
45
|
+
{field.label}
|
|
46
|
+
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
47
|
+
</label>
|
|
48
|
+
|
|
49
|
+
{field.type === 'textarea' ? (
|
|
50
|
+
<textarea
|
|
51
|
+
{...register(field.name)}
|
|
52
|
+
rows={4}
|
|
53
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
54
|
+
/>
|
|
55
|
+
) : field.type === 'boolean' ? (
|
|
56
|
+
<input
|
|
57
|
+
type="checkbox"
|
|
58
|
+
{...register(field.name)}
|
|
59
|
+
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
60
|
+
/>
|
|
61
|
+
) : field.type === 'select' ? (
|
|
62
|
+
<select
|
|
63
|
+
{...register(field.name)}
|
|
64
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
65
|
+
>
|
|
66
|
+
<option value="">Select...</option>
|
|
67
|
+
{field.options ? (
|
|
68
|
+
field.options.map((opt: { value: string; label: string }) => (
|
|
69
|
+
<option key={opt.value} value={opt.value}>
|
|
70
|
+
{opt.label}
|
|
71
|
+
</option>
|
|
72
|
+
))
|
|
73
|
+
) : (
|
|
74
|
+
relationOptions[field.name]?.map((opt: { id: string; [key: string]: string }) => (
|
|
75
|
+
<option key={opt.id} value={opt.id}>
|
|
76
|
+
{opt[field.relation!.display]}
|
|
77
|
+
</option>
|
|
78
|
+
))
|
|
79
|
+
)}
|
|
80
|
+
</select>
|
|
81
|
+
) : field.type === 'image' ? (
|
|
82
|
+
<ImageUpload
|
|
83
|
+
name={field.name}
|
|
84
|
+
control={control}
|
|
85
|
+
defaultValue={watch(field.name)}
|
|
86
|
+
/>
|
|
87
|
+
) : (
|
|
88
|
+
<input
|
|
89
|
+
type={field.type}
|
|
90
|
+
{...register(field.name)}
|
|
91
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{errors[field.name] && (
|
|
96
|
+
<p className="text-red-500 text-sm mt-1">
|
|
97
|
+
{errors[field.name]?.message as string}
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
))}
|
|
102
|
+
|
|
103
|
+
<div className="pt-4">
|
|
104
|
+
<SubmitButton
|
|
105
|
+
isSubmitting={isSubmitting}
|
|
106
|
+
label="Save"
|
|
107
|
+
submittingLabel="Saving..."
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Control, Controller } from "react-hook-form";
|
|
4
|
+
import { Image as ImageIcon, Upload, X, Loader2 } from "lucide-react";
|
|
5
|
+
import Image from "next/image";
|
|
6
|
+
import { uploadImage } from "@/app/actions/upload";
|
|
7
|
+
import { useState, useRef, useEffect } from "react";
|
|
8
|
+
import { toast } from "react-toastify";
|
|
9
|
+
|
|
10
|
+
interface ImageUploadProps {
|
|
11
|
+
name: string;
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
control: Control<any>;
|
|
14
|
+
defaultValue?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function ImageUpload({ name, control, defaultValue }: ImageUploadProps) {
|
|
18
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
19
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
20
|
+
const isMounted = useRef(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
isMounted.current = true;
|
|
24
|
+
return () => {
|
|
25
|
+
isMounted.current = false;
|
|
26
|
+
};
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const handleUpload = async (file: File, onChange: (value: string) => void) => {
|
|
30
|
+
setIsUploading(true);
|
|
31
|
+
const formData = new FormData();
|
|
32
|
+
formData.append("file", file);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const result = await uploadImage(formData);
|
|
36
|
+
|
|
37
|
+
// Check if mounted before updating state
|
|
38
|
+
if (!isMounted.current) return;
|
|
39
|
+
|
|
40
|
+
if (result.error) {
|
|
41
|
+
toast.error(result.error);
|
|
42
|
+
} else if (result.url) {
|
|
43
|
+
onChange(result.url);
|
|
44
|
+
toast.success("Image uploaded successfully");
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(error);
|
|
48
|
+
if (isMounted.current) toast.error("Upload failed");
|
|
49
|
+
} finally {
|
|
50
|
+
if (isMounted.current) setIsUploading(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (value: string) => void) => {
|
|
55
|
+
if (e.target.files && e.target.files[0]) {
|
|
56
|
+
handleUpload(e.target.files[0], onChange);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Controller
|
|
62
|
+
name={name}
|
|
63
|
+
control={control}
|
|
64
|
+
render={({ field: { onChange, value } }) => {
|
|
65
|
+
const currentImage = value || defaultValue;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="space-y-4">
|
|
69
|
+
{!currentImage ? (
|
|
70
|
+
<div
|
|
71
|
+
onClick={() => fileInputRef.current?.click()}
|
|
72
|
+
className="border-2 border-dashed border-gray-300 rounded-lg p-6 flex flex-col items-center justify-center cursor-pointer hover:border-blue-500 transition-colors bg-gray-50 hover:bg-white"
|
|
73
|
+
>
|
|
74
|
+
<input
|
|
75
|
+
ref={fileInputRef}
|
|
76
|
+
type="file"
|
|
77
|
+
accept="image/*"
|
|
78
|
+
className="hidden"
|
|
79
|
+
onChange={(e) => onFileInputChange(e, onChange)}
|
|
80
|
+
/>
|
|
81
|
+
|
|
82
|
+
{isUploading ? (
|
|
83
|
+
<div className="flex flex-col items-center">
|
|
84
|
+
<Loader2 className="w-8 h-8 text-blue-500 animate-spin mb-2" />
|
|
85
|
+
<p className="text-sm text-gray-500">Uploading...</p>
|
|
86
|
+
</div>
|
|
87
|
+
) : (
|
|
88
|
+
<>
|
|
89
|
+
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
|
90
|
+
<p className="text-sm text-gray-500 font-medium">
|
|
91
|
+
Click to upload image
|
|
92
|
+
</p>
|
|
93
|
+
<p className="text-xs text-gray-400 mt-1">
|
|
94
|
+
SVG, PNG, JPG or GIF (max 5MB)
|
|
95
|
+
</p>
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="relative w-full h-64 rounded-lg overflow-hidden border border-gray-200 bg-gray-100 group">
|
|
101
|
+
<Image
|
|
102
|
+
src={currentImage}
|
|
103
|
+
alt="Upload preview"
|
|
104
|
+
fill
|
|
105
|
+
className="object-contain"
|
|
106
|
+
/>
|
|
107
|
+
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => onChange("")}
|
|
111
|
+
className="bg-red-500 text-white p-1 rounded-full hover:bg-red-600 transition-colors shadow-lg"
|
|
112
|
+
>
|
|
113
|
+
<X className="w-4 h-4" />
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{/* Fallback Input for manual URL entry (optional, hidden by default but good for debugging or external URLs) */}
|
|
120
|
+
<div className="mt-2 text-xs text-gray-400 flex justify-end">
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => {
|
|
124
|
+
const url = prompt("Enter image URL manually:", currentImage);
|
|
125
|
+
if (url !== null) onChange(url);
|
|
126
|
+
}}
|
|
127
|
+
className="hover:underline"
|
|
128
|
+
>
|
|
129
|
+
Enter URL manually
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useForm, FormProvider } from "react-hook-form";
|
|
4
|
+
import FormLayout from "./FormLayout";
|
|
5
|
+
import { ResourceConfig } from "@/config/resources";
|
|
6
|
+
import { Trash2 } from "lucide-react";
|
|
7
|
+
import { useResource } from "@/hooks/useResource";
|
|
8
|
+
|
|
9
|
+
interface ResourceFormClientProps {
|
|
10
|
+
config: ResourceConfig;
|
|
11
|
+
mode: "create" | "update";
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
initialData?: any;
|
|
14
|
+
id?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function ResourceFormClient({
|
|
18
|
+
config,
|
|
19
|
+
mode,
|
|
20
|
+
initialData,
|
|
21
|
+
id,
|
|
22
|
+
}: ResourceFormClientProps) {
|
|
23
|
+
const { create, update, remove, isSubmitting, isDeleting } = useResource(config);
|
|
24
|
+
|
|
25
|
+
const methods = useForm({
|
|
26
|
+
defaultValues: initialData || {},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
const onSubmit = async (data: any) => {
|
|
31
|
+
if (mode === "create") {
|
|
32
|
+
await create(data);
|
|
33
|
+
} else {
|
|
34
|
+
await update(id!, data);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<FormProvider {...methods}>
|
|
40
|
+
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
|
41
|
+
{mode === 'update' && (
|
|
42
|
+
<div className="flex justify-end mb-4">
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => remove(id!)}
|
|
46
|
+
disabled={isDeleting}
|
|
47
|
+
className="flex items-center text-red-600 hover:text-red-800 px-3 py-2 rounded bg-red-50 hover:bg-red-100 transition-colors"
|
|
48
|
+
>
|
|
49
|
+
<Trash2 className="w-4 h-4 mr-2" />
|
|
50
|
+
{isDeleting ? "Deleting..." : "Delete Record"}
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
<FormLayout
|
|
55
|
+
fields={config.fields}
|
|
56
|
+
title={`${mode === "create" ? "New" : "Edit"} ${config.singular}`}
|
|
57
|
+
isSubmitting={isSubmitting}
|
|
58
|
+
/>
|
|
59
|
+
</form>
|
|
60
|
+
</FormProvider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { resources } from "@/config/resources";
|
|
6
|
+
import * as Icons from "lucide-react";
|
|
7
|
+
import { LogOut } from "lucide-react";
|
|
8
|
+
import { createBrowserClient } from "@supabase/ssr";
|
|
9
|
+
import { useRouter } from "next/navigation";
|
|
10
|
+
|
|
11
|
+
export default function Sidebar() {
|
|
12
|
+
const pathname = usePathname();
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
|
|
15
|
+
const handleLogout = async () => {
|
|
16
|
+
const supabase = createBrowserClient(
|
|
17
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
18
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
19
|
+
);
|
|
20
|
+
await supabase.auth.signOut();
|
|
21
|
+
router.push("/login");
|
|
22
|
+
router.refresh();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex flex-col w-64 bg-gray-900 h-screen text-white sticky top-0">
|
|
27
|
+
<div className="p-6 border-b border-gray-800">
|
|
28
|
+
<h1 className="text-2xl font-bold">Admin Panel</h1>
|
|
29
|
+
</div>
|
|
30
|
+
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
|
|
31
|
+
<Link
|
|
32
|
+
href="/dashboard"
|
|
33
|
+
className={`flex items-center px-4 py-3 rounded-lg transition-colors ${
|
|
34
|
+
pathname === "/dashboard"
|
|
35
|
+
? "bg-blue-600 text-white"
|
|
36
|
+
: "text-gray-400 hover:bg-gray-800 hover:text-white"
|
|
37
|
+
}`}
|
|
38
|
+
>
|
|
39
|
+
<Icons.LayoutDashboard className="w-5 h-5 mr-3" />
|
|
40
|
+
Dashboard
|
|
41
|
+
</Link>
|
|
42
|
+
|
|
43
|
+
{resources.map((resource) => {
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const Icon = (Icons as any)[resource.icon] || Icons.Box;
|
|
46
|
+
const isActive = pathname.startsWith(resource.path);
|
|
47
|
+
return (
|
|
48
|
+
<Link
|
|
49
|
+
key={resource.name}
|
|
50
|
+
href={resource.path}
|
|
51
|
+
className={`flex items-center px-4 py-3 rounded-lg transition-colors ${
|
|
52
|
+
isActive
|
|
53
|
+
? "bg-blue-600 text-white"
|
|
54
|
+
: "text-gray-400 hover:bg-gray-800 hover:text-white"
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
<Icon className="w-5 h-5 mr-3" />
|
|
58
|
+
{resource.plural}
|
|
59
|
+
</Link>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</nav>
|
|
63
|
+
<div className="p-4 border-t border-gray-800">
|
|
64
|
+
<button
|
|
65
|
+
onClick={handleLogout}
|
|
66
|
+
className="w-full flex items-center px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
|
|
67
|
+
>
|
|
68
|
+
<LogOut className="w-4 h-4 mr-2" />
|
|
69
|
+
Logout
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
interface SubmitButtonProps {
|
|
6
|
+
isSubmitting: boolean;
|
|
7
|
+
label: string;
|
|
8
|
+
submittingLabel: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function SubmitButton({
|
|
13
|
+
isSubmitting,
|
|
14
|
+
label,
|
|
15
|
+
submittingLabel,
|
|
16
|
+
className = "",
|
|
17
|
+
}: SubmitButtonProps) {
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
type="submit"
|
|
21
|
+
disabled={isSubmitting}
|
|
22
|
+
className={`flex items-center justify-center w-full sm:w-auto px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${className}`}
|
|
23
|
+
>
|
|
24
|
+
{isSubmitting ? (
|
|
25
|
+
<>
|
|
26
|
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
27
|
+
{submittingLabel}
|
|
28
|
+
</>
|
|
29
|
+
) : (
|
|
30
|
+
label
|
|
31
|
+
)}
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import ResourceFormClient from "@/components/admin/ResourceFormClient";
|
|
4
|
+
import { resources } from "@/config/resources";
|
|
5
|
+
|
|
6
|
+
interface CategoryFormProps {
|
|
7
|
+
mode: "create" | "update";
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
initialData?: any;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function CategoryForm({ mode, initialData, id }: CategoryFormProps) {
|
|
14
|
+
const config = resources.find((r) => r.name === "categories")!;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<ResourceFormClient
|
|
18
|
+
config={config}
|
|
19
|
+
mode={mode}
|
|
20
|
+
initialData={initialData}
|
|
21
|
+
id={id}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ResourceConfig } from "@/config/resources";
|
|
5
|
+
import { useResource } from "@/hooks/useResource";
|
|
6
|
+
import { Pencil, Trash2 } from "lucide-react";
|
|
7
|
+
|
|
8
|
+
interface CategoryListProps {
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
items: any[];
|
|
11
|
+
config: ResourceConfig;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import DeleteModal from "@/components/admin/DeleteModal";
|
|
16
|
+
|
|
17
|
+
export default function CategoryList({ items, config }: CategoryListProps) {
|
|
18
|
+
const { remove, isDeleting } = useResource(config);
|
|
19
|
+
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
20
|
+
|
|
21
|
+
const handleDelete = async () => {
|
|
22
|
+
if (deleteId) {
|
|
23
|
+
const success = await remove(deleteId);
|
|
24
|
+
if (success) {
|
|
25
|
+
setDeleteId(null);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
33
|
+
<div className="overflow-x-auto">
|
|
34
|
+
<table className="w-full text-left">
|
|
35
|
+
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-semibold">
|
|
36
|
+
<tr>
|
|
37
|
+
<th className="px-6 py-3">Title</th>
|
|
38
|
+
<th className="px-6 py-3">Slug</th>
|
|
39
|
+
<th className="px-6 py-3">Featured</th>
|
|
40
|
+
<th className="px-6 py-3">Published</th>
|
|
41
|
+
<th className="px-6 py-3 text-right">Actions</th>
|
|
42
|
+
</tr>
|
|
43
|
+
</thead>
|
|
44
|
+
<tbody className="divide-y divide-gray-200">
|
|
45
|
+
{items?.map((item) => (
|
|
46
|
+
<tr key={item.id} className="hover:bg-gray-50 transition">
|
|
47
|
+
<td className="px-6 py-4 text-gray-900 font-medium">{item.title}</td>
|
|
48
|
+
<td className="px-6 py-4 text-gray-500">{item.slug}</td>
|
|
49
|
+
<td className="px-6 py-4">
|
|
50
|
+
<span
|
|
51
|
+
className={`px-2 py-1 text-xs rounded-full ${
|
|
52
|
+
item.featured
|
|
53
|
+
? "bg-yellow-100 text-yellow-700"
|
|
54
|
+
: "bg-gray-100 text-gray-600"
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
{item.featured ? "Yes" : "No"}
|
|
58
|
+
</span>
|
|
59
|
+
</td>
|
|
60
|
+
<td className="px-6 py-4">
|
|
61
|
+
<span
|
|
62
|
+
className={`px-2 py-1 text-xs rounded-full ${
|
|
63
|
+
item.published
|
|
64
|
+
? "bg-green-100 text-green-700"
|
|
65
|
+
: "bg-gray-100 text-gray-600"
|
|
66
|
+
}`}
|
|
67
|
+
>
|
|
68
|
+
{item.published ? "Published" : "Draft"}
|
|
69
|
+
</span>
|
|
70
|
+
</td>
|
|
71
|
+
<td className="px-6 py-4 text-right">
|
|
72
|
+
<div className="flex justify-end gap-2">
|
|
73
|
+
<Link
|
|
74
|
+
href={`/categories/${item.id}`}
|
|
75
|
+
className="flex items-center text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
76
|
+
title="Edit"
|
|
77
|
+
>
|
|
78
|
+
<Pencil className="w-4 h-4" />
|
|
79
|
+
</Link>
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => setDeleteId(item.id)}
|
|
82
|
+
className="flex items-center text-red-600 hover:text-red-900 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded-md transition-colors duration-200"
|
|
83
|
+
title="Delete"
|
|
84
|
+
>
|
|
85
|
+
<Trash2 className="w-4 h-4" />
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
))}
|
|
91
|
+
{items?.length === 0 && (
|
|
92
|
+
<tr>
|
|
93
|
+
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
|
|
94
|
+
No categories found.
|
|
95
|
+
</td>
|
|
96
|
+
</tr>
|
|
97
|
+
)}
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<DeleteModal
|
|
104
|
+
isOpen={!!deleteId}
|
|
105
|
+
onClose={() => setDeleteId(null)}
|
|
106
|
+
onConfirm={handleDelete}
|
|
107
|
+
isLoading={isDeleting}
|
|
108
|
+
title="Delete Category"
|
|
109
|
+
description="Are you sure you want to delete this category? This action cannot be undone."
|
|
110
|
+
/>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|