@navneet_25/tempjs 2.0.0 → 3.0.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/README.md +66 -18
- package/cli/brand-manager.js +183 -96
- package/cli/config.js +9 -1
- package/cli/doctor.js +393 -0
- package/cli/fetch-core-modules.js +130 -0
- package/cli/index.js +141 -10
- package/cli/info.js +28 -1
- package/cli/init-options.js +209 -0
- package/cli/module-manager.js +168 -0
- package/cli/parse-args.js +21 -0
- package/cli/update.js +15 -0
- package/cli/version-manager.js +518 -0
- package/package.json +27 -4
- package/packages/core/modules/README.md +72 -0
- package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
- package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
- package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
- package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
- package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
- package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
- package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
- package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
- package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
- package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
- package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
- package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
- package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
- package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
- package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
- package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
- package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
- package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
- package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
- package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
- package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
- package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
- package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
- package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
- package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
- package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
- package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
- package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
- package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
- package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
- package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
- package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
- package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
- package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
- package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
- package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
- package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
- package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
- package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
- package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
- package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
- package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
- package/packages/core/modules/seo/app/robots.ts +30 -0
- package/packages/core/modules/seo/app/sitemap.ts +9 -0
- package/packages/core/modules/seo/lib/seo/index.ts +5 -0
- package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
- package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
- package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
- package/packages/core/modules/seo/lib/seo/types.ts +30 -0
- package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
- package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
- package/packages/core/modules.json +140 -0
- package/packages/core/prisma/schema.prisma +33 -0
- package/scripts/module-installer-core.mjs +657 -0
- package/scripts/template-modules-admin.mjs +366 -0
- package/scripts/template-modules-home.mjs +120 -0
- package/templates.json +163 -8
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { Plus, Edit2, Trash2, Images } from "lucide-react";
|
|
6
|
+
import GalleryFormModal from "./GalleryFormModal";
|
|
7
|
+
import {
|
|
8
|
+
useGetGallery,
|
|
9
|
+
useCreateGalleryImage,
|
|
10
|
+
useUpdateGalleryImage,
|
|
11
|
+
useDeleteGalleryImage,
|
|
12
|
+
GalleryDataInput,
|
|
13
|
+
} from "../hooks/useGallery";
|
|
14
|
+
|
|
15
|
+
export default function GalleryList() {
|
|
16
|
+
const { data: items = [], isLoading, error } = useGetGallery();
|
|
17
|
+
const createMutation = useCreateGalleryImage();
|
|
18
|
+
const updateMutation = useUpdateGalleryImage();
|
|
19
|
+
const deleteMutation = useDeleteGalleryImage();
|
|
20
|
+
|
|
21
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
22
|
+
const [selected, setSelected] = useState<GalleryDataInput | null>(null);
|
|
23
|
+
const [submitError, setSubmitError] = useState("");
|
|
24
|
+
|
|
25
|
+
const handleSubmit = async (data: GalleryDataInput) => {
|
|
26
|
+
setSubmitError("");
|
|
27
|
+
try {
|
|
28
|
+
if (data.id) await updateMutation.mutateAsync(data);
|
|
29
|
+
else await createMutation.mutateAsync(data);
|
|
30
|
+
setModalOpen(false);
|
|
31
|
+
} catch (err: unknown) {
|
|
32
|
+
setSubmitError(err instanceof Error ? err.message : "Failed to save");
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleDelete = async (id: string) => {
|
|
37
|
+
if (!confirm("Delete this gallery image?")) return;
|
|
38
|
+
try {
|
|
39
|
+
await deleteMutation.mutateAsync(id);
|
|
40
|
+
} catch (err: unknown) {
|
|
41
|
+
alert(err instanceof Error ? err.message : "Failed to delete");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (isLoading) {
|
|
46
|
+
return <div className="p-8 text-sm text-text-muted">Loading gallery...</div>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-6">
|
|
51
|
+
<div className="flex items-center justify-between">
|
|
52
|
+
<div>
|
|
53
|
+
<h2 className="text-xl font-bold font-serif text-primary flex items-center gap-2">
|
|
54
|
+
<Images className="w-5 h-5" />
|
|
55
|
+
Gallery
|
|
56
|
+
</h2>
|
|
57
|
+
<p className="text-xs text-text-muted mt-1">Manage images for the gallery page and homepage section.</p>
|
|
58
|
+
</div>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => {
|
|
62
|
+
setSelected(null);
|
|
63
|
+
setSubmitError("");
|
|
64
|
+
setModalOpen(true);
|
|
65
|
+
}}
|
|
66
|
+
className="flex items-center gap-1.5 px-4 py-2 bg-primary text-white text-xs font-bold rounded-lg cursor-pointer"
|
|
67
|
+
>
|
|
68
|
+
<Plus className="w-4 h-4" />
|
|
69
|
+
Add image
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{error && (
|
|
74
|
+
<div className="p-3 text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg">
|
|
75
|
+
{(error as Error).message}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{items.length === 0 ? (
|
|
80
|
+
<div className="text-center py-12 border border-dashed rounded-2xl text-xs text-text-muted">
|
|
81
|
+
No gallery images yet.
|
|
82
|
+
</div>
|
|
83
|
+
) : (
|
|
84
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
85
|
+
{items.map((item) => (
|
|
86
|
+
<div
|
|
87
|
+
key={item.id}
|
|
88
|
+
className="rounded-2xl border border-primary/10 bg-white overflow-hidden shadow-sm"
|
|
89
|
+
>
|
|
90
|
+
<div className="relative aspect-video bg-bg-light">
|
|
91
|
+
<Image src={item.imageUrl} alt={item.title} fill className="object-cover" />
|
|
92
|
+
</div>
|
|
93
|
+
<div className="p-4 space-y-2">
|
|
94
|
+
<div className="flex justify-between items-start gap-2">
|
|
95
|
+
<div>
|
|
96
|
+
<p className="font-bold text-sm text-primary">{item.title}</p>
|
|
97
|
+
<p className="text-[10px] uppercase tracking-wider text-text-muted">{item.category}</p>
|
|
98
|
+
</div>
|
|
99
|
+
{!item.published && (
|
|
100
|
+
<span className="text-[9px] font-bold uppercase bg-amber-100 text-amber-800 px-2 py-0.5 rounded">
|
|
101
|
+
Hidden
|
|
102
|
+
</span>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex gap-2 pt-2">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => {
|
|
109
|
+
setSelected(item);
|
|
110
|
+
setModalOpen(true);
|
|
111
|
+
}}
|
|
112
|
+
className="flex items-center gap-1 text-xs font-bold text-primary cursor-pointer"
|
|
113
|
+
>
|
|
114
|
+
<Edit2 className="w-3.5 h-3.5" />
|
|
115
|
+
Edit
|
|
116
|
+
</button>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => item.id && handleDelete(item.id)}
|
|
120
|
+
className="flex items-center gap-1 text-xs font-bold text-red-600 cursor-pointer"
|
|
121
|
+
>
|
|
122
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
123
|
+
Delete
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
<GalleryFormModal
|
|
133
|
+
isOpen={modalOpen}
|
|
134
|
+
onClose={() => setModalOpen(false)}
|
|
135
|
+
onSubmit={handleSubmit}
|
|
136
|
+
initialData={selected}
|
|
137
|
+
error={submitError}
|
|
138
|
+
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
export interface GalleryDataInput {
|
|
6
|
+
id?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
category: string;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
imageUrl: string;
|
|
11
|
+
sortOrder: number;
|
|
12
|
+
published: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function fetchGallery(): Promise<GalleryDataInput[]> {
|
|
16
|
+
const res = await fetch("/api/gallery");
|
|
17
|
+
const json = await res.json();
|
|
18
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to load gallery");
|
|
19
|
+
return json.data;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useGetGallery() {
|
|
23
|
+
return useQuery({ queryKey: ["gallery"], queryFn: fetchGallery });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useCreateGalleryImage() {
|
|
27
|
+
const qc = useQueryClient();
|
|
28
|
+
return useMutation({
|
|
29
|
+
mutationFn: async (data: GalleryDataInput) => {
|
|
30
|
+
const res = await fetch("/api/gallery", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify(data),
|
|
34
|
+
});
|
|
35
|
+
const json = await res.json();
|
|
36
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to create");
|
|
37
|
+
return json.data;
|
|
38
|
+
},
|
|
39
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["gallery"] }),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useUpdateGalleryImage() {
|
|
44
|
+
const qc = useQueryClient();
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: async (data: GalleryDataInput) => {
|
|
47
|
+
if (!data.id) throw new Error("Missing id");
|
|
48
|
+
const res = await fetch(`/api/gallery/${data.id}`, {
|
|
49
|
+
method: "PUT",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(data),
|
|
52
|
+
});
|
|
53
|
+
const json = await res.json();
|
|
54
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to update");
|
|
55
|
+
return json.data;
|
|
56
|
+
},
|
|
57
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["gallery"] }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useDeleteGalleryImage() {
|
|
62
|
+
const qc = useQueryClient();
|
|
63
|
+
return useMutation({
|
|
64
|
+
mutationFn: async (id: string) => {
|
|
65
|
+
const res = await fetch(`/api/gallery/${id}`, { method: "DELETE" });
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
if (!res.ok || !json.success) throw new Error(json.error || "Failed to delete");
|
|
68
|
+
return json.data;
|
|
69
|
+
},
|
|
70
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: ["gallery"] }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { galleryController } from "@/lib/controllers/GalleryController";
|
|
3
|
+
import { auth } from "@/auth";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export async function PUT(
|
|
8
|
+
req: NextRequest,
|
|
9
|
+
context: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const session = await auth();
|
|
12
|
+
if (!session) {
|
|
13
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
14
|
+
}
|
|
15
|
+
const { id } = await context.params;
|
|
16
|
+
return galleryController.update(req, { id });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function DELETE(
|
|
20
|
+
_req: NextRequest,
|
|
21
|
+
context: { params: Promise<{ id: string }> }
|
|
22
|
+
) {
|
|
23
|
+
const session = await auth();
|
|
24
|
+
if (!session) {
|
|
25
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
26
|
+
}
|
|
27
|
+
const { id } = await context.params;
|
|
28
|
+
return galleryController.delete({ id });
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { galleryController } from "@/lib/controllers/GalleryController";
|
|
3
|
+
import { auth } from "@/auth";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
export async function GET(req: NextRequest) {
|
|
8
|
+
const session = await auth();
|
|
9
|
+
const admin = Boolean(session);
|
|
10
|
+
if (admin) {
|
|
11
|
+
return galleryController.listAll();
|
|
12
|
+
}
|
|
13
|
+
return galleryController.listPublic();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST(req: NextRequest) {
|
|
17
|
+
const session = await auth();
|
|
18
|
+
if (!session) {
|
|
19
|
+
return Response.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
return galleryController.create(req);
|
|
22
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { ArrowUpRight, Images } from "lucide-react";
|
|
7
|
+
import { SITE } from "@/constants";
|
|
8
|
+
|
|
9
|
+
interface GalleryItem {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
category: string;
|
|
13
|
+
description?: string | null;
|
|
14
|
+
imageUrl: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function GallerySection() {
|
|
18
|
+
const [items, setItems] = useState<GalleryItem[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
async function load() {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch("/api/gallery");
|
|
25
|
+
const json = await res.json();
|
|
26
|
+
if (json.success && Array.isArray(json.data)) {
|
|
27
|
+
setItems(json.data.slice(0, 6));
|
|
28
|
+
}
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.warn("GallerySection: failed to load", err);
|
|
31
|
+
} finally {
|
|
32
|
+
setLoading(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
load();
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
if (!loading && items.length === 0) return null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section className="py-20 md:py-24 bg-bg-light border-t border-primary/5">
|
|
42
|
+
<div className="max-w-7xl mx-auto px-6 md:px-8">
|
|
43
|
+
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-10">
|
|
44
|
+
<div>
|
|
45
|
+
<span className="text-[10px] font-extrabold tracking-[0.25em] uppercase text-accent-gold-dark">
|
|
46
|
+
{SITE.gallery.sectionEyebrow}
|
|
47
|
+
</span>
|
|
48
|
+
<h2 className="text-2xl md:text-3xl font-bold font-serif text-primary mt-2">
|
|
49
|
+
{SITE.gallery.sectionTitle}
|
|
50
|
+
</h2>
|
|
51
|
+
<p className="text-sm text-text-muted mt-2 max-w-xl">{SITE.gallery.sectionSubtitle}</p>
|
|
52
|
+
</div>
|
|
53
|
+
<Link
|
|
54
|
+
href="/gallery"
|
|
55
|
+
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-primary hover:text-primary-hover transition-colors"
|
|
56
|
+
>
|
|
57
|
+
{SITE.gallery.viewAllLabel}
|
|
58
|
+
<ArrowUpRight className="w-4 h-4" />
|
|
59
|
+
</Link>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{loading ? (
|
|
63
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
64
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
65
|
+
<div key={i} className="aspect-[4/3] rounded-2xl bg-bg-card/60 animate-pulse" />
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
) : (
|
|
69
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
70
|
+
{items.map((item) => (
|
|
71
|
+
<div
|
|
72
|
+
key={item.id}
|
|
73
|
+
className="group relative aspect-[4/3] rounded-2xl overflow-hidden bg-bg-card shadow-sm border border-primary/5"
|
|
74
|
+
>
|
|
75
|
+
<Image
|
|
76
|
+
src={item.imageUrl}
|
|
77
|
+
alt={item.title}
|
|
78
|
+
fill
|
|
79
|
+
sizes="(max-width: 768px) 50vw, 33vw"
|
|
80
|
+
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
|
81
|
+
/>
|
|
82
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end p-4">
|
|
83
|
+
<span className="text-white text-sm font-semibold">{item.title}</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { SITE } from "@/constants";
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: `${SITE.gallery.pageTitle} | ${SITE.brand.shortName}`,
|
|
6
|
+
description: SITE.gallery.pageSubtitle,
|
|
7
|
+
alternates: { canonical: `${SITE.domain.baseUrl.replace(/\/$/, "")}/gallery` },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function GalleryLayout({ children }: { children: React.ReactNode }) {
|
|
11
|
+
return children;
|
|
12
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { ArrowLeft, ChevronLeft, ChevronRight, X, ZoomIn } from "lucide-react";
|
|
7
|
+
import { SITE } from "@/constants";
|
|
8
|
+
import { buildPageMetadata } from "@/lib/seo/metadata";
|
|
9
|
+
|
|
10
|
+
interface GalleryItem {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
category: string;
|
|
14
|
+
description?: string | null;
|
|
15
|
+
imageUrl: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function GalleryPage() {
|
|
19
|
+
const [items, setItems] = useState<GalleryItem[]>([]);
|
|
20
|
+
const [filter, setFilter] = useState<string>("all");
|
|
21
|
+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
async function load() {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch("/api/gallery");
|
|
27
|
+
const json = await res.json();
|
|
28
|
+
if (json.success && Array.isArray(json.data)) {
|
|
29
|
+
setItems(json.data);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.warn("Gallery page: failed to load", err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
load();
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const categories = Array.from(new Set(items.map((i) => i.category))).sort();
|
|
39
|
+
const filtered =
|
|
40
|
+
filter === "all" ? items : items.filter((i) => i.category === filter);
|
|
41
|
+
|
|
42
|
+
const closeLightbox = () => setLightboxIndex(null);
|
|
43
|
+
const showPrev = () =>
|
|
44
|
+
setLightboxIndex((idx) =>
|
|
45
|
+
idx == null ? null : (idx - 1 + filtered.length) % filtered.length
|
|
46
|
+
);
|
|
47
|
+
const showNext = () =>
|
|
48
|
+
setLightboxIndex((idx) =>
|
|
49
|
+
idx == null ? null : (idx + 1) % filtered.length
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="min-h-screen bg-bg-main">
|
|
54
|
+
<header className="border-b border-primary/10 bg-white/80 backdrop-blur-md sticky top-0 z-30">
|
|
55
|
+
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
56
|
+
<Link
|
|
57
|
+
href="/"
|
|
58
|
+
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:text-primary-hover"
|
|
59
|
+
>
|
|
60
|
+
<ArrowLeft className="w-4 h-4" />
|
|
61
|
+
{SITE.navigation.backToHome}
|
|
62
|
+
</Link>
|
|
63
|
+
<h1 className="text-lg font-bold font-serif text-primary">{SITE.gallery.pageTitle}</h1>
|
|
64
|
+
</div>
|
|
65
|
+
</header>
|
|
66
|
+
|
|
67
|
+
<main className="max-w-7xl mx-auto px-6 py-12">
|
|
68
|
+
<p className="text-text-muted text-sm mb-8 max-w-2xl">{SITE.gallery.pageSubtitle}</p>
|
|
69
|
+
|
|
70
|
+
{categories.length > 1 && (
|
|
71
|
+
<div className="flex flex-wrap gap-2 mb-8">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={() => setFilter("all")}
|
|
75
|
+
className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-wider transition-colors ${
|
|
76
|
+
filter === "all"
|
|
77
|
+
? "bg-primary text-white"
|
|
78
|
+
: "bg-white border border-primary/10 text-text-muted hover:text-primary"
|
|
79
|
+
}`}
|
|
80
|
+
>
|
|
81
|
+
All
|
|
82
|
+
</button>
|
|
83
|
+
{categories.map((cat) => (
|
|
84
|
+
<button
|
|
85
|
+
key={cat}
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => setFilter(cat)}
|
|
88
|
+
className={`px-4 py-2 rounded-full text-xs font-bold uppercase tracking-wider transition-colors ${
|
|
89
|
+
filter === cat
|
|
90
|
+
? "bg-primary text-white"
|
|
91
|
+
: "bg-white border border-primary/10 text-text-muted hover:text-primary"
|
|
92
|
+
}`}
|
|
93
|
+
>
|
|
94
|
+
{cat}
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{filtered.length === 0 ? (
|
|
101
|
+
<p className="text-center text-text-muted py-20">{SITE.gallery.emptyMessage}</p>
|
|
102
|
+
) : (
|
|
103
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
104
|
+
{filtered.map((item, index) => (
|
|
105
|
+
<button
|
|
106
|
+
key={item.id}
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => setLightboxIndex(index)}
|
|
109
|
+
className="group relative aspect-square rounded-2xl overflow-hidden border border-primary/5 shadow-sm cursor-pointer"
|
|
110
|
+
>
|
|
111
|
+
<Image
|
|
112
|
+
src={item.imageUrl}
|
|
113
|
+
alt={item.title}
|
|
114
|
+
fill
|
|
115
|
+
sizes="(max-width: 768px) 50vw, 25vw"
|
|
116
|
+
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
|
117
|
+
/>
|
|
118
|
+
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
|
|
119
|
+
<ZoomIn className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
120
|
+
</div>
|
|
121
|
+
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
|
|
122
|
+
<span className="text-white text-xs font-semibold line-clamp-1">{item.title}</span>
|
|
123
|
+
</div>
|
|
124
|
+
</button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</main>
|
|
129
|
+
|
|
130
|
+
{lightboxIndex != null && filtered[lightboxIndex] && (
|
|
131
|
+
<div
|
|
132
|
+
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
|
|
133
|
+
onClick={closeLightbox}
|
|
134
|
+
>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="absolute top-6 right-6 text-white/80 hover:text-white"
|
|
138
|
+
onClick={closeLightbox}
|
|
139
|
+
aria-label="Close"
|
|
140
|
+
>
|
|
141
|
+
<X className="w-8 h-8" />
|
|
142
|
+
</button>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="absolute left-4 md:left-8 text-white/80 hover:text-white p-2"
|
|
146
|
+
onClick={(e) => {
|
|
147
|
+
e.stopPropagation();
|
|
148
|
+
showPrev();
|
|
149
|
+
}}
|
|
150
|
+
aria-label="Previous"
|
|
151
|
+
>
|
|
152
|
+
<ChevronLeft className="w-10 h-10" />
|
|
153
|
+
</button>
|
|
154
|
+
<div className="relative max-w-5xl w-full max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
|
|
155
|
+
<Image
|
|
156
|
+
src={filtered[lightboxIndex].imageUrl}
|
|
157
|
+
alt={filtered[lightboxIndex].title}
|
|
158
|
+
width={1200}
|
|
159
|
+
height={800}
|
|
160
|
+
className="w-full h-auto max-h-[85vh] object-contain rounded-lg"
|
|
161
|
+
/>
|
|
162
|
+
<p className="text-white text-center mt-4 font-semibold">{filtered[lightboxIndex].title}</p>
|
|
163
|
+
</div>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className="absolute right-4 md:right-8 text-white/80 hover:text-white p-2"
|
|
167
|
+
onClick={(e) => {
|
|
168
|
+
e.stopPropagation();
|
|
169
|
+
showNext();
|
|
170
|
+
}}
|
|
171
|
+
aria-label="Next"
|
|
172
|
+
>
|
|
173
|
+
<ChevronRight className="w-10 h-10" />
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { galleryService, GalleryService } from "./gallery.service";
|
|
3
|
+
|
|
4
|
+
export class GalleryController {
|
|
5
|
+
private service: GalleryService;
|
|
6
|
+
|
|
7
|
+
constructor(service: GalleryService = galleryService) {
|
|
8
|
+
this.service = service;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async listPublic(): Promise<NextResponse> {
|
|
12
|
+
try {
|
|
13
|
+
const data = await this.service.listPublic();
|
|
14
|
+
return NextResponse.json({ success: true, data }, { status: 200 });
|
|
15
|
+
} catch (error: unknown) {
|
|
16
|
+
const message = error instanceof Error ? error.message : "Failed to fetch gallery";
|
|
17
|
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async listAll(): Promise<NextResponse> {
|
|
22
|
+
try {
|
|
23
|
+
const data = await this.service.listAll();
|
|
24
|
+
return NextResponse.json({ success: true, data }, { status: 200 });
|
|
25
|
+
} catch (error: unknown) {
|
|
26
|
+
const message = error instanceof Error ? error.message : "Failed to fetch gallery";
|
|
27
|
+
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async create(req: Request): Promise<NextResponse> {
|
|
32
|
+
try {
|
|
33
|
+
const body = await req.json();
|
|
34
|
+
const item = await this.service.create({
|
|
35
|
+
title: body.title,
|
|
36
|
+
category: body.category,
|
|
37
|
+
description: body.description,
|
|
38
|
+
imageUrl: body.imageUrl,
|
|
39
|
+
sortOrder: body.sortOrder != null ? Number(body.sortOrder) : 0,
|
|
40
|
+
published: body.published !== false,
|
|
41
|
+
});
|
|
42
|
+
return NextResponse.json({ success: true, data: item }, { status: 201 });
|
|
43
|
+
} catch (error: unknown) {
|
|
44
|
+
const message = error instanceof Error ? error.message : "Failed to create gallery image";
|
|
45
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async update(req: Request, params: { id: string }): Promise<NextResponse> {
|
|
50
|
+
try {
|
|
51
|
+
const body = await req.json();
|
|
52
|
+
const item = await this.service.update(params.id, {
|
|
53
|
+
title: body.title,
|
|
54
|
+
category: body.category,
|
|
55
|
+
description: body.description,
|
|
56
|
+
imageUrl: body.imageUrl,
|
|
57
|
+
sortOrder: body.sortOrder != null ? Number(body.sortOrder) : undefined,
|
|
58
|
+
published: body.published,
|
|
59
|
+
});
|
|
60
|
+
return NextResponse.json({ success: true, data: item }, { status: 200 });
|
|
61
|
+
} catch (error: unknown) {
|
|
62
|
+
const message = error instanceof Error ? error.message : "Failed to update gallery image";
|
|
63
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async delete(params: { id: string }): Promise<NextResponse> {
|
|
68
|
+
try {
|
|
69
|
+
const item = await this.service.delete(params.id);
|
|
70
|
+
return NextResponse.json({ success: true, data: item }, { status: 200 });
|
|
71
|
+
} catch (error: unknown) {
|
|
72
|
+
const message = error instanceof Error ? error.message : "Failed to delete gallery image";
|
|
73
|
+
return NextResponse.json({ success: false, error: message }, { status: 400 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const galleryController = new GalleryController();
|