@morphika/andami 0.1.3 → 0.1.6

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 (105) hide show
  1. package/app/(site)/[slug]/page.tsx +7 -4
  2. package/app/(site)/layout.tsx +5 -2
  3. package/app/(site)/page.tsx +2 -2
  4. package/app/(site)/preview/page.tsx +4 -4
  5. package/app/(site)/work/[slug]/page.tsx +7 -4
  6. package/app/admin/layout.tsx +3 -2
  7. package/app/admin/login/page.tsx +5 -5
  8. package/app/admin/navigation/page.tsx +255 -157
  9. package/app/api/admin/assets/health/route.ts +1 -1
  10. package/app/api/admin/assets/register/route.ts +1 -1
  11. package/app/api/admin/assets/registry/route.ts +1 -1
  12. package/app/api/admin/assets/relink/confirm/route.ts +2 -2
  13. package/app/api/admin/assets/relink/route.ts +1 -1
  14. package/app/api/admin/assets/scan/route.ts +1 -1
  15. package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
  16. package/app/api/admin/custom-sections/route.ts +1 -1
  17. package/app/api/admin/database/route.ts +1 -1
  18. package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
  19. package/app/api/admin/pages/[slug]/route.ts +2 -2
  20. package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
  21. package/app/api/admin/pages/route.ts +1 -1
  22. package/app/api/admin/preview/route.ts +1 -1
  23. package/app/api/admin/r2/delete/route.ts +1 -1
  24. package/app/api/admin/r2/rename/route.ts +1 -1
  25. package/app/api/admin/r2/status/route.ts +1 -1
  26. package/app/api/admin/r2/upload-url/route.ts +1 -1
  27. package/app/api/admin/settings/route.ts +41 -16
  28. package/app/api/admin/setup/complete/route.ts +2 -2
  29. package/app/api/admin/setup/route.ts +7 -4
  30. package/app/api/admin/storage/switch/route.ts +1 -1
  31. package/app/api/admin/styles/route.ts +1 -1
  32. package/components/admin/index.ts +7 -0
  33. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  34. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  35. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  36. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  37. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  38. package/components/admin/nav-builder/index.ts +2 -0
  39. package/components/blocks/BlockRenderer.tsx +65 -13
  40. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  41. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  42. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  43. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  44. package/components/blocks/PageRenderer.tsx +4 -2
  45. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  46. package/components/blocks/SectionRenderer.tsx +9 -8
  47. package/components/blocks/SectionV2Renderer.tsx +8 -8
  48. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  49. package/components/blocks/TextBlockRenderer.tsx +9 -4
  50. package/components/builder/BuilderCanvas.tsx +10 -4
  51. package/components/builder/ColorPicker.tsx +51 -243
  52. package/components/builder/ColorSwatchPicker.tsx +214 -274
  53. package/components/builder/DndWrapper.tsx +5 -2
  54. package/components/builder/SectionV2Canvas.tsx +15 -4
  55. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  57. package/components/builder/color-picker/AngleControl.tsx +138 -0
  58. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  59. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  60. package/components/builder/color-picker/GradientBar.tsx +222 -0
  61. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  62. package/components/builder/color-picker/HueSlider.tsx +124 -0
  63. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  64. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  65. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  66. package/components/builder/color-picker/PositionControl.tsx +158 -0
  67. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  68. package/components/builder/color-picker/StopEditor.tsx +178 -0
  69. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  70. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  71. package/components/builder/color-picker/index.ts +62 -0
  72. package/components/builder/color-picker/types.ts +115 -0
  73. package/components/builder/color-picker/utils.ts +138 -0
  74. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  75. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  76. package/components/builder/hooks/useColumnDrag.ts +25 -27
  77. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  78. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  79. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  80. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  81. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  82. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  83. package/components/ui/Navbar.tsx +97 -25
  84. package/components/ui/PortfolioTracker.tsx +3 -3
  85. package/lib/assets.ts +1 -1
  86. package/lib/auth.ts +1 -1
  87. package/lib/builder/gradient-presets.ts +128 -0
  88. package/lib/builder/layout-styles.ts +16 -10
  89. package/lib/builder/serializer.ts +1 -0
  90. package/lib/builder/store-blocks.ts +48 -61
  91. package/lib/builder/store-helpers.ts +31 -14
  92. package/lib/builder/store.ts +59 -41
  93. package/lib/builder/types.ts +14 -0
  94. package/lib/color-utils.ts +200 -0
  95. package/lib/revalidate.ts +2 -2
  96. package/lib/sanity/client.ts +16 -0
  97. package/lib/sanity/queries.ts +4 -3
  98. package/lib/sanity/types.ts +76 -1
  99. package/lib/setup/detect.ts +1 -1
  100. package/lib/storage/index.ts +22 -4
  101. package/lib/version.ts +6 -0
  102. package/package.json +8 -2
  103. package/sanity/schemas/siteSettings.ts +34 -0
  104. package/styles/base.css +3 -3
  105. package/app/globals.css +0 -7
@@ -1,3 +1,4 @@
1
+ import { cache } from "react";
1
2
  import { notFound } from "next/navigation";
2
3
  import type { Metadata } from "next";
3
4
  import { client } from "../../../lib/sanity/client";
@@ -16,19 +17,21 @@ interface PageProps {
16
17
  params: Promise<{ slug: string }>;
17
18
  }
18
19
 
19
- async function getPageBySlug(slug: string): Promise<Page | null> {
20
+ // CPU Audit Fix 3: React cache() deduplicates this call within a single
21
+ // render pass — generateMetadata() and the page component share one fetch.
22
+ const getPageBySlug = cache(async (slug: string): Promise<Page | null> => {
20
23
  try {
21
24
  return await client.fetch<Page>(publishedPageBySlugQuery, { slug });
22
25
  } catch (error) {
23
26
  console.error(`[Page] Failed to fetch page "${slug}":`, error);
24
27
  return null;
25
28
  }
26
- }
29
+ });
27
30
 
28
31
  export async function generateStaticParams() {
29
32
  try {
30
- const slugs: string[] = await client.fetch(allPageSlugsQuery);
31
- return slugs.map((slug) => ({ slug }));
33
+ const slugs = await client.fetch<string[] | null>(allPageSlugsQuery);
34
+ return (slugs ?? []).filter(Boolean).map((slug) => ({ slug }));
32
35
  } catch {
33
36
  return [];
34
37
  }
@@ -1,3 +1,4 @@
1
+ import { cache } from "react";
1
2
  import { client } from "../../lib/sanity/client";
2
3
  import { siteSettingsQuery } from "../../lib/sanity/queries";
3
4
  import type { SiteSettings } from "../../lib/sanity/types";
@@ -11,14 +12,15 @@ import CustomCursor from "../../components/ui/CustomCursor";
11
12
  import ScrollToTop from "../../components/ui/ScrollToTop";
12
13
  import PortfolioTracker from "../../components/ui/PortfolioTracker";
13
14
 
14
- async function getSiteSettings(): Promise<SiteSettings | null> {
15
+ // CPU Audit Fix 3: React cache() deduplicates within a render pass.
16
+ const getSiteSettings = cache(async (): Promise<SiteSettings | null> => {
15
17
  try {
16
18
  return await client.fetch<SiteSettings>(siteSettingsQuery);
17
19
  } catch (error) {
18
20
  console.error("[SiteLayout] Failed to fetch site settings:", error);
19
21
  return null;
20
22
  }
21
- }
23
+ });
22
24
 
23
25
  export default async function SiteLayout({
24
26
  children,
@@ -39,6 +41,7 @@ export default async function SiteLayout({
39
41
  <Navbar
40
42
  navItems={settings?.nav_items}
41
43
  design={settings?.nav_design}
44
+ mobileDesign={settings?.nav_mobile_design}
42
45
  />
43
46
  <main className="flex-1">{children}</main>
44
47
  {cfg.features.customCursor && <CustomCursor />}
@@ -24,10 +24,10 @@ export default async function HomePage() {
24
24
  return (
25
25
  <main className="flex min-h-screen items-center justify-center bg-brand-dark">
26
26
  <div className="text-center">
27
- <h1 className="font-mono text-2xl uppercase tracking-widest text-brand-accent">
27
+ <h1 className="font-sans text-2xl uppercase tracking-widest text-brand-accent">
28
28
  {cfg.name}
29
29
  </h1>
30
- <p className="mt-4 font-mono text-sm uppercase tracking-wider text-brand-muted">
30
+ <p className="mt-4 font-sans text-sm uppercase tracking-wider text-brand-muted">
31
31
  Coming Soon
32
32
  </p>
33
33
  </div>
@@ -53,7 +53,7 @@ function PreviewContent() {
53
53
  if (loading) {
54
54
  return (
55
55
  <div className="flex min-h-[50vh] items-center justify-center">
56
- <p className="font-mono text-sm text-neutral-400 animate-pulse">
56
+ <p className="font-sans text-sm text-neutral-400 animate-pulse">
57
57
  Loading preview...
58
58
  </p>
59
59
  </div>
@@ -64,10 +64,10 @@ function PreviewContent() {
64
64
  return (
65
65
  <div className="flex min-h-[50vh] items-center justify-center">
66
66
  <div className="text-center">
67
- <p className="font-mono text-sm text-[var(--admin-error)]">
67
+ <p className="font-sans text-sm text-[var(--admin-error)]">
68
68
  {error || "Page not found"}
69
69
  </p>
70
- <p className="mt-2 font-mono text-xs text-neutral-400">
70
+ <p className="mt-2 font-sans text-xs text-neutral-400">
71
71
  Make sure the page has been saved at least once.
72
72
  </p>
73
73
  </div>
@@ -87,7 +87,7 @@ export default function PreviewPage() {
87
87
  <Suspense
88
88
  fallback={
89
89
  <div className="flex min-h-[50vh] items-center justify-center">
90
- <p className="font-mono text-sm text-neutral-400 animate-pulse">
90
+ <p className="font-sans text-sm text-neutral-400 animate-pulse">
91
91
  Loading preview...
92
92
  </p>
93
93
  </div>
@@ -1,3 +1,4 @@
1
+ import { cache } from "react";
1
2
  import { notFound } from "next/navigation";
2
3
  import type { Metadata } from "next";
3
4
  import { client } from "../../../../lib/sanity/client";
@@ -17,19 +18,21 @@ interface ProjectPageProps {
17
18
  params: Promise<{ slug: string }>;
18
19
  }
19
20
 
20
- async function getProjectPage(slug: string): Promise<Page | null> {
21
+ // CPU Audit Fix 3: React cache() deduplicates this call within a single
22
+ // render pass — generateMetadata() and the page component share one fetch.
23
+ const getProjectPage = cache(async (slug: string): Promise<Page | null> => {
21
24
  try {
22
25
  return await client.fetch<Page>(publishedProjectBySlugQuery, { slug });
23
26
  } catch (error) {
24
27
  console.error(`[Project] Failed to fetch project "${slug}":`, error);
25
28
  return null;
26
29
  }
27
- }
30
+ });
28
31
 
29
32
  export async function generateStaticParams() {
30
33
  try {
31
- const slugs = await client.fetch<string[]>(allProjectSlugsQuery);
32
- return slugs.map((slug) => ({ slug }));
34
+ const slugs = await client.fetch<string[] | null>(allProjectSlugsQuery);
35
+ return (slugs ?? []).filter(Boolean).map((slug) => ({ slug }));
33
36
  } catch {
34
37
  return [];
35
38
  }
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation";
4
4
  import Link from "next/link";
5
5
  import { useState, useEffect, useRef } from "react";
6
6
  import { getSiteConfig } from "../../lib/config";
7
+ import { ANDAMI_VERSION } from "../../lib/version";
7
8
 
8
9
  // ============================================
9
10
  // Navigation Configuration — flat list, no sections
@@ -161,8 +162,8 @@ export default function AdminLayout({
161
162
  <div className="flex h-14 items-center justify-between px-4 border-b border-white/[0.06]">
162
163
  {sidebarOpen && (
163
164
  <span className="text-[10px] font-semibold tracking-widest uppercase text-white/90 leading-tight">
164
- {getSiteConfig().name.split(" ")[0]}<br />
165
- <span className="text-white/50 font-normal">Web Builder</span>
165
+ Morphika Andami <span className="text-white/40 font-normal">v{ANDAMI_VERSION}</span><br />
166
+ <span className="text-white/50 font-normal">{getSiteConfig().name}</span>
166
167
  </span>
167
168
  )}
168
169
  <button
@@ -42,10 +42,10 @@ function LoginForm() {
42
42
  <div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]">
43
43
  <div className="w-full max-w-sm px-6">
44
44
  <div className="mb-8 text-center">
45
- <h1 className="font-mono text-xl uppercase tracking-widest text-white">
45
+ <h1 className="font-sans text-xl uppercase tracking-widest text-white">
46
46
  {getSiteConfig().adminTitle}
47
47
  </h1>
48
- <p className="mt-2 font-mono text-xs uppercase tracking-wider text-neutral-500">
48
+ <p className="mt-2 font-sans text-xs uppercase tracking-wider text-neutral-500">
49
49
  Enter password to continue
50
50
  </p>
51
51
  </div>
@@ -58,18 +58,18 @@ function LoginForm() {
58
58
  onChange={(e) => setPassword(e.target.value)}
59
59
  placeholder="Password"
60
60
  autoFocus
61
- className="w-full rounded-none border border-neutral-700 bg-transparent px-4 py-3 font-mono text-sm text-white placeholder-neutral-600 outline-none transition-colors focus:border-neutral-400"
61
+ className="w-full rounded-none border border-neutral-700 bg-transparent px-4 py-3 font-sans text-sm text-white placeholder-neutral-600 outline-none transition-colors focus:border-neutral-400"
62
62
  />
63
63
  </div>
64
64
 
65
65
  {error && (
66
- <p className="font-mono text-xs text-red-500">{error}</p>
66
+ <p className="font-sans text-xs text-red-500">{error}</p>
67
67
  )}
68
68
 
69
69
  <button
70
70
  type="submit"
71
71
  disabled={isLoading || !password}
72
- className="w-full border border-white bg-white px-4 py-3 font-mono text-sm uppercase tracking-wider text-black transition-all hover:bg-transparent hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
72
+ className="w-full border border-white bg-white px-4 py-3 font-sans text-sm uppercase tracking-wider text-black transition-all hover:bg-transparent hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
73
73
  >
74
74
  {isLoading ? "..." : "Login"}
75
75
  </button>
@@ -1,157 +1,255 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback } from "react";
4
- import { csrfHeaders } from "../../../lib/csrf-client";
5
- import { NavBuilder } from "../../../components/admin/nav-builder";
6
- import { revalidateSite } from "../../../lib/revalidate";
7
- import type { NavItem, NavDesign, SiteSettings } from "../../../lib/sanity/types";
8
-
9
- /**
10
- * /admin/navigation — Navigation editing.
11
- * Session 129: Footer tab removed footers are now custom sections placed freely by the user.
12
- */
13
- export default function AdminNavigationPage() {
14
- const [settings, setSettings] = useState<SiteSettings | null>(null);
15
- const [loading, setLoading] = useState(true);
16
- const [savingNav, setSavingNav] = useState(false);
17
- const [message, setMessage] = useState<{
18
- type: "success" | "error";
19
- text: string;
20
- } | null>(null);
21
-
22
- // ── Load settings ──
23
- const fetchSettings = useCallback(async () => {
24
- try {
25
- const res = await fetch("/api/admin/settings");
26
- if (res.ok) {
27
- const data = await res.json();
28
- setSettings(data.settings || null);
29
- }
30
- } catch {
31
- setMessage({ type: "error", text: "Failed to load navigation" });
32
- } finally {
33
- setLoading(false);
34
- }
35
- }, []);
36
-
37
- useEffect(() => {
38
- fetchSettings();
39
- }, [fetchSettings]);
40
-
41
- // ── Auto-dismiss messages ──
42
- useEffect(() => {
43
- if (!message) return;
44
- const timer = setTimeout(() => setMessage(null), 5000);
45
- return () => clearTimeout(timer);
46
- }, [message]);
47
-
48
- // ── Save handlers ──
49
- const handleSaveNavBuilder = async (navItems: NavItem[], navDesign: NavDesign) => {
50
- setSavingNav(true);
51
- setMessage(null);
52
- try {
53
- // Sanitize items for Sanity
54
- const sanitizedItems = navItems.map((item) => ({
55
- _key: item._key,
56
- type: item.type || "menu-item",
57
- label: item.label,
58
- ...(item.logo_image ? { logo_image: item.logo_image } : {}),
59
- link_type: item.link_type,
60
- ...(item.link_type === "internal" && item.internal_page
61
- ? { internal_page: { _ref: item.internal_page._id } }
62
- : {}),
63
- ...(item.link_type === "external"
64
- ? { external_url: item.external_url }
65
- : {}),
66
- ...(item.link_type === "content" ? {
67
- ...(item.content_type ? { content_type: item.content_type } : {}),
68
- ...(item.content_asset ? { content_asset: item.content_asset } : {}),
69
- ...(item.content_url ? { content_url: item.content_url } : {}),
70
- } : {}),
71
- visible: item.visible,
72
- grid_column: item.grid_column,
73
- column_span: item.column_span || 1,
74
- ...(item.style_overrides && Object.keys(item.style_overrides).length > 0
75
- ? { style_overrides: item.style_overrides }
76
- : {}),
77
- }));
78
-
79
- // Save nav items
80
- const navRes = await fetch("/api/admin/settings", {
81
- method: "POST",
82
- headers: { "Content-Type": "application/json", ...csrfHeaders() },
83
- body: JSON.stringify({ section: "navigation", data: { nav_items: sanitizedItems } }),
84
- });
85
- if (!navRes.ok) {
86
- const errData = await navRes.json();
87
- throw new Error(errData.error || "Save items failed");
88
- }
89
-
90
- // Save nav design
91
- const designRes = await fetch("/api/admin/settings", {
92
- method: "POST",
93
- headers: { "Content-Type": "application/json", ...csrfHeaders() },
94
- body: JSON.stringify({ section: "nav_design", data: { nav_design: navDesign } }),
95
- });
96
- if (!designRes.ok) {
97
- const errData = await designRes.json();
98
- throw new Error(errData.error || "Save design failed");
99
- }
100
-
101
- setMessage({ type: "success", text: "Navigation saved" });
102
-
103
- // Refresh settings
104
- const settingsRes = await fetch("/api/admin/settings");
105
- if (settingsRes.ok) {
106
- const settingsData = await settingsRes.json();
107
- setSettings(settingsData.settings || null);
108
- }
109
- revalidateSite();
110
- } catch (err) {
111
- setMessage({
112
- type: "error",
113
- text: err instanceof Error ? err.message : "Save failed",
114
- });
115
- } finally {
116
- setSavingNav(false);
117
- }
118
- };
119
-
120
- if (loading) {
121
- return (
122
- <div className="flex items-center justify-center py-20">
123
- <span className="text-sm text-neutral-400 animate-pulse">
124
- Loading navigation...
125
- </span>
126
- </div>
127
- );
128
- }
129
-
130
- return (
131
- <div className="space-y-6">
132
- <h1 className="text-2xl font-semibold text-neutral-900">
133
- Navigation
134
- </h1>
135
-
136
- {/* Message banner */}
137
- {message && (
138
- <div
139
- className={`p-3 rounded-xl border transition-opacity ${
140
- message.type === "success"
141
- ? "border-green-200 bg-green-50 text-green-700"
142
- : "border-red-200 bg-red-50 text-red-700"
143
- }`}
144
- >
145
- <p className="text-sm">{message.text}</p>
146
- </div>
147
- )}
148
-
149
- <NavBuilder
150
- initialItems={settings?.nav_items || []}
151
- initialDesign={settings?.nav_design || {}}
152
- onSave={handleSaveNavBuilder}
153
- saving={savingNav}
154
- />
155
- </div>
156
- );
157
- }
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { csrfHeaders } from "../../../lib/csrf-client";
5
+ import { NavBuilder } from "../../../components/admin/nav-builder";
6
+ import NavMobileSettings from "../../../components/admin/nav-builder/NavMobileSettings";
7
+ import { revalidateSite } from "../../../lib/revalidate";
8
+ import type { NavItem, NavDesign, MobileNavDesign, SiteSettings } from "../../../lib/sanity/types";
9
+
10
+ /**
11
+ * /admin/navigationNavigation editing.
12
+ * Session 129: Footer tab removed — footers are now custom sections placed freely by the user.
13
+ * Session 158: Added "Mobile Menu" tab for independent mobile menu styles.
14
+ */
15
+
16
+ type NavTabId = "desktop" | "mobile";
17
+
18
+ const TABS: { id: NavTabId; label: string }[] = [
19
+ { id: "desktop", label: "Desktop" },
20
+ { id: "mobile", label: "Mobile Menu" },
21
+ ];
22
+
23
+ export default function AdminNavigationPage() {
24
+ const [settings, setSettings] = useState<SiteSettings | null>(null);
25
+ const [loading, setLoading] = useState(true);
26
+ const [activeTab, setActiveTab] = useState<NavTabId>("desktop");
27
+ const [savingNav, setSavingNav] = useState(false);
28
+ const [savingMobile, setSavingMobile] = useState(false);
29
+ const [mobileDesign, setMobileDesign] = useState<MobileNavDesign>({});
30
+ const [mobileHasChanges, setMobileHasChanges] = useState(false);
31
+ const [message, setMessage] = useState<{
32
+ type: "success" | "error";
33
+ text: string;
34
+ } | null>(null);
35
+
36
+ // ── Load settings ──
37
+ const fetchSettings = useCallback(async () => {
38
+ try {
39
+ const res = await fetch("/api/admin/settings");
40
+ if (res.ok) {
41
+ const data = await res.json();
42
+ setSettings(data.settings || null);
43
+ }
44
+ } catch {
45
+ setMessage({ type: "error", text: "Failed to load navigation" });
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ fetchSettings();
53
+ }, [fetchSettings]);
54
+
55
+ // Sync mobile design from loaded settings
56
+ useEffect(() => {
57
+ if (settings?.nav_mobile_design) {
58
+ setMobileDesign(settings.nav_mobile_design);
59
+ }
60
+ }, [settings]);
61
+
62
+ // ── Auto-dismiss messages ──
63
+ useEffect(() => {
64
+ if (!message) return;
65
+ const timer = setTimeout(() => setMessage(null), 5000);
66
+ return () => clearTimeout(timer);
67
+ }, [message]);
68
+
69
+ // ── Save handlers ──
70
+ const handleSaveNavBuilder = async (navItems: NavItem[], navDesign: NavDesign) => {
71
+ setSavingNav(true);
72
+ setMessage(null);
73
+ try {
74
+ // Sanitize items for Sanity
75
+ const sanitizedItems = navItems.map((item) => ({
76
+ _key: item._key,
77
+ type: item.type || "menu-item",
78
+ label: item.label,
79
+ ...(item.logo_image ? { logo_image: item.logo_image } : {}),
80
+ link_type: item.link_type,
81
+ ...(item.link_type === "internal" && item.internal_page
82
+ ? { internal_page: { _ref: item.internal_page._id } }
83
+ : {}),
84
+ ...(item.link_type === "external"
85
+ ? { external_url: item.external_url }
86
+ : {}),
87
+ ...(item.link_type === "content" ? {
88
+ ...(item.content_type ? { content_type: item.content_type } : {}),
89
+ ...(item.content_asset ? { content_asset: item.content_asset } : {}),
90
+ ...(item.content_url ? { content_url: item.content_url } : {}),
91
+ } : {}),
92
+ visible: item.visible,
93
+ grid_column: item.grid_column,
94
+ column_span: item.column_span || 1,
95
+ ...(item.style_overrides && Object.keys(item.style_overrides).length > 0
96
+ ? { style_overrides: item.style_overrides }
97
+ : {}),
98
+ }));
99
+
100
+ // Save nav items
101
+ const navRes = await fetch("/api/admin/settings", {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
104
+ body: JSON.stringify({ section: "navigation", data: { nav_items: sanitizedItems } }),
105
+ });
106
+ if (!navRes.ok) {
107
+ const errData = await navRes.json();
108
+ throw new Error(errData.error || "Save items failed");
109
+ }
110
+
111
+ // Save nav design
112
+ const designRes = await fetch("/api/admin/settings", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
115
+ body: JSON.stringify({ section: "nav_design", data: { nav_design: navDesign } }),
116
+ });
117
+ if (!designRes.ok) {
118
+ const errData = await designRes.json();
119
+ throw new Error(errData.error || "Save design failed");
120
+ }
121
+
122
+ setMessage({ type: "success", text: "Navigation saved" });
123
+
124
+ // Refresh settings
125
+ const settingsRes = await fetch("/api/admin/settings");
126
+ if (settingsRes.ok) {
127
+ const settingsData = await settingsRes.json();
128
+ setSettings(settingsData.settings || null);
129
+ }
130
+ revalidateSite();
131
+ } catch (err) {
132
+ setMessage({
133
+ type: "error",
134
+ text: err instanceof Error ? err.message : "Save failed",
135
+ });
136
+ } finally {
137
+ setSavingNav(false);
138
+ }
139
+ };
140
+
141
+ // ── Mobile menu design handlers ──
142
+ const handleMobileDesignChange = useCallback((newDesign: MobileNavDesign) => {
143
+ setMobileDesign(newDesign);
144
+ setMobileHasChanges(true);
145
+ }, []);
146
+
147
+ const handleSaveMobileDesign = useCallback(async () => {
148
+ setSavingMobile(true);
149
+ setMessage(null);
150
+ try {
151
+ const res = await fetch("/api/admin/settings", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
154
+ body: JSON.stringify({
155
+ section: "nav_mobile_design",
156
+ data: { nav_mobile_design: mobileDesign },
157
+ }),
158
+ });
159
+ if (!res.ok) {
160
+ const errData = await res.json();
161
+ throw new Error(errData.error || "Save mobile design failed");
162
+ }
163
+ setMobileHasChanges(false);
164
+ setMessage({ type: "success", text: "Mobile menu saved" });
165
+
166
+ // Refresh settings
167
+ const settingsRes = await fetch("/api/admin/settings");
168
+ if (settingsRes.ok) {
169
+ const settingsData = await settingsRes.json();
170
+ setSettings(settingsData.settings || null);
171
+ }
172
+ revalidateSite();
173
+ } catch (err) {
174
+ setMessage({
175
+ type: "error",
176
+ text: err instanceof Error ? err.message : "Save failed",
177
+ });
178
+ } finally {
179
+ setSavingMobile(false);
180
+ }
181
+ }, [mobileDesign]);
182
+
183
+ if (loading) {
184
+ return (
185
+ <div className="flex items-center justify-center py-20">
186
+ <span className="text-sm text-neutral-400 animate-pulse">
187
+ Loading navigation...
188
+ </span>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ return (
194
+ <div className="space-y-6">
195
+ <h1 className="text-2xl font-semibold text-neutral-900">
196
+ Navigation
197
+ </h1>
198
+
199
+ {/* Tab navigation — matches admin Customize style */}
200
+ <div className="flex items-center gap-1 border-b border-neutral-200">
201
+ {TABS.map((tab) => {
202
+ const isActive = activeTab === tab.id;
203
+ return (
204
+ <button
205
+ key={tab.id}
206
+ onClick={() => setActiveTab(tab.id)}
207
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors border-b-2 -mb-px ${
208
+ isActive
209
+ ? "text-neutral-900 border-[#076bff]"
210
+ : "text-neutral-400 border-transparent hover:text-neutral-600"
211
+ }`}
212
+ >
213
+ {tab.label}
214
+ </button>
215
+ );
216
+ })}
217
+ </div>
218
+
219
+ {/* Toast message */}
220
+ {message && (
221
+ <div
222
+ className={`p-3 rounded-xl border transition-opacity ${
223
+ message.type === "success"
224
+ ? "border-green-200 bg-green-50 text-green-700"
225
+ : "border-red-200 bg-red-50 text-red-700"
226
+ }`}
227
+ >
228
+ <p className="text-sm">{message.text}</p>
229
+ </div>
230
+ )}
231
+
232
+ {/* Tab content */}
233
+ {activeTab === "desktop" && (
234
+ <NavBuilder
235
+ initialItems={settings?.nav_items || []}
236
+ initialDesign={settings?.nav_design || {}}
237
+ onSave={handleSaveNavBuilder}
238
+ saving={savingNav}
239
+ />
240
+ )}
241
+
242
+ {activeTab === "mobile" && (
243
+ <NavMobileSettings
244
+ design={mobileDesign}
245
+ desktopDesign={settings?.nav_design || {}}
246
+ items={settings?.nav_items || []}
247
+ onChange={handleMobileDesignChange}
248
+ onSave={handleSaveMobileDesign}
249
+ saving={savingMobile}
250
+ hasChanges={mobileHasChanges}
251
+ />
252
+ )}
253
+ </div>
254
+ );
255
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { client } from "../../../../../lib/sanity/client";
2
+ import { adminClient as client } from "../../../../../lib/sanity/client";
3
3
  import { writeClient } from "../../../../../lib/sanity/writeClient";
4
4
  import { isAdminAuthenticated } from "../../../../../lib/auth";
5
5
  import { logger } from "../../../../../lib/logger";
@@ -3,7 +3,7 @@ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
3
  import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
4
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath } from "../../../../../lib/security";
5
5
  import { writeClient } from "../../../../../lib/sanity/writeClient";
6
- import { client } from "../../../../../lib/sanity/client";
6
+ import { adminClient as client } from "../../../../../lib/sanity/client";
7
7
  import { isMediaFile, getMimeType } from "../../../../../lib/storage/types";
8
8
  import { auditLog } from "../../../../../lib/audit";
9
9
  import { logger } from "../../../../../lib/logger";
@@ -1,5 +1,5 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { client } from "../../../../../lib/sanity/client";
2
+ import { adminClient as client } from "../../../../../lib/sanity/client";
3
3
  import { writeClient } from "../../../../../lib/sanity/writeClient";
4
4
  import { assetRegistryQuery } from "../../../../../lib/sanity/queries";
5
5
  import { isAdminAuthenticated } from "../../../../../lib/auth";