@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.
Files changed (90) hide show
  1. package/README.md +66 -18
  2. package/cli/brand-manager.js +183 -96
  3. package/cli/config.js +9 -1
  4. package/cli/doctor.js +393 -0
  5. package/cli/fetch-core-modules.js +130 -0
  6. package/cli/index.js +141 -10
  7. package/cli/info.js +28 -1
  8. package/cli/init-options.js +209 -0
  9. package/cli/module-manager.js +168 -0
  10. package/cli/parse-args.js +21 -0
  11. package/cli/update.js +15 -0
  12. package/cli/version-manager.js +518 -0
  13. package/package.json +27 -4
  14. package/packages/core/modules/README.md +72 -0
  15. package/packages/core/modules/blog-compose/app/admin/components/blog-compose-form.ts +44 -0
  16. package/packages/core/modules/blog-compose/app/admin/hooks/useBlogPosts.ts +103 -0
  17. package/packages/core/modules/blog-compose/app/admin/panels/BlogComposePanel.tsx +388 -0
  18. package/packages/core/modules/blog-compose/app/api/blog-posts/[id]/route.ts +29 -0
  19. package/packages/core/modules/blog-compose/app/api/blog-posts/route.ts +21 -0
  20. package/packages/core/modules/blog-compose/app/blog/[slug]/page.tsx +79 -0
  21. package/packages/core/modules/blog-compose/app/blog/layout.tsx +22 -0
  22. package/packages/core/modules/blog-compose/app/blog/page.tsx +102 -0
  23. package/packages/core/modules/blog-compose/app/components/BlogSection.tsx +74 -0
  24. package/packages/core/modules/blog-compose/app/components/BlogShell.tsx +36 -0
  25. package/packages/core/modules/blog-compose/app/components/BlogSidebar.tsx +112 -0
  26. package/packages/core/modules/blog-compose/lib/blog/compose/annotate-text.tsx +103 -0
  27. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/media-blocks.tsx +97 -0
  28. package/packages/core/modules/blog-compose/lib/blog/compose/blocks/text-blocks.tsx +115 -0
  29. package/packages/core/modules/blog-compose/lib/blog/compose/index.ts +21 -0
  30. package/packages/core/modules/blog-compose/lib/blog/compose/interpreter.tsx +55 -0
  31. package/packages/core/modules/blog-compose/lib/blog/compose/registry.ts +276 -0
  32. package/packages/core/modules/blog-compose/lib/blog/compose/types.ts +128 -0
  33. package/packages/core/modules/blog-compose/lib/blog/register-sitemap.ts +18 -0
  34. package/packages/core/modules/blog-compose/lib/controllers/BlogComposeController.ts +2 -0
  35. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.controller.ts +78 -0
  36. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.repository.ts +78 -0
  37. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.service.ts +72 -0
  38. package/packages/core/modules/blog-compose/lib/features/blog-compose/blog-compose.types.ts +20 -0
  39. package/packages/core/modules/blog-compose/lib/features/blog-compose/index.ts +7 -0
  40. package/packages/core/modules/blog-compose/prisma/blog-compose.prisma +13 -0
  41. package/packages/core/modules/enquiry-modal/app/components/EnquiryModal.tsx +308 -0
  42. package/packages/core/modules/footer/app/components/Footer.tsx +127 -0
  43. package/packages/core/modules/gallery/app/admin/components/GalleryFormModal.tsx +132 -0
  44. package/packages/core/modules/gallery/app/admin/components/GalleryList.tsx +142 -0
  45. package/packages/core/modules/gallery/app/admin/hooks/useGallery.ts +72 -0
  46. package/packages/core/modules/gallery/app/api/gallery/[id]/route.ts +29 -0
  47. package/packages/core/modules/gallery/app/api/gallery/route.ts +22 -0
  48. package/packages/core/modules/gallery/app/components/GallerySection.tsx +92 -0
  49. package/packages/core/modules/gallery/app/gallery/layout.tsx +12 -0
  50. package/packages/core/modules/gallery/app/gallery/page.tsx +179 -0
  51. package/packages/core/modules/gallery/lib/controllers/GalleryController.ts +2 -0
  52. package/packages/core/modules/gallery/lib/features/gallery/gallery.controller.ts +78 -0
  53. package/packages/core/modules/gallery/lib/features/gallery/gallery.repository.ts +55 -0
  54. package/packages/core/modules/gallery/lib/features/gallery/gallery.service.ts +43 -0
  55. package/packages/core/modules/gallery/lib/features/gallery/gallery.types.ts +17 -0
  56. package/packages/core/modules/gallery/lib/features/gallery/index.ts +4 -0
  57. package/packages/core/modules/gallery/prisma/gallery.prisma +11 -0
  58. package/packages/core/modules/hero-simple/app/components/Hero.tsx +160 -0
  59. package/packages/core/modules/legal-pages/app/components/LegalDocumentPage.tsx +61 -0
  60. package/packages/core/modules/legal-pages/app/privacy-policy/page.tsx +19 -0
  61. package/packages/core/modules/legal-pages/app/terms-and-conditions/page.tsx +19 -0
  62. package/packages/core/modules/reviews/app/admin/components/ReviewFormModal.tsx +110 -0
  63. package/packages/core/modules/reviews/app/admin/components/ReviewsList.tsx +116 -0
  64. package/packages/core/modules/reviews/app/admin/hooks/useReviews.ts +70 -0
  65. package/packages/core/modules/reviews/app/api/reviews/[id]/route.ts +29 -0
  66. package/packages/core/modules/reviews/app/api/reviews/route.ts +17 -0
  67. package/packages/core/modules/reviews/app/components/ReviewsSection.tsx +100 -0
  68. package/packages/core/modules/reviews/lib/controllers/ReviewController.ts +2 -0
  69. package/packages/core/modules/reviews/lib/features/reviews/index.ts +3 -0
  70. package/packages/core/modules/reviews/lib/features/reviews/review.controller.ts +64 -0
  71. package/packages/core/modules/reviews/lib/features/reviews/review.repository.ts +50 -0
  72. package/packages/core/modules/reviews/lib/features/reviews/review.service.ts +38 -0
  73. package/packages/core/modules/reviews/prisma/reviews.prisma +9 -0
  74. package/packages/core/modules/seo/app/components/SiteJsonLd.tsx +16 -0
  75. package/packages/core/modules/seo/app/robots.ts +30 -0
  76. package/packages/core/modules/seo/app/sitemap.ts +9 -0
  77. package/packages/core/modules/seo/lib/seo/index.ts +5 -0
  78. package/packages/core/modules/seo/lib/seo/json-ld.ts +128 -0
  79. package/packages/core/modules/seo/lib/seo/metadata.ts +65 -0
  80. package/packages/core/modules/seo/lib/seo/sitemap.ts +82 -0
  81. package/packages/core/modules/seo/lib/seo/types.ts +30 -0
  82. package/packages/core/modules/seo/lib/seo/urls.ts +27 -0
  83. package/packages/core/modules/theme-modes/app/components/ThemeModeInit.tsx +24 -0
  84. package/packages/core/modules/theme-modes/app/components/ThemeModeToggle.tsx +58 -0
  85. package/packages/core/modules.json +140 -0
  86. package/packages/core/prisma/schema.prisma +33 -0
  87. package/scripts/module-installer-core.mjs +657 -0
  88. package/scripts/template-modules-admin.mjs +366 -0
  89. package/scripts/template-modules-home.mjs +120 -0
  90. 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,2 @@
1
+ /** @deprecated Import from `@/lib/features/gallery` instead. */
2
+ export { GalleryController, galleryController } from "@/lib/features/gallery";
@@ -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();