@morphika/andami 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +27 -2
  2. package/app/admin/layout.tsx +26 -14
  3. package/app/admin/pages/[slug]/page.tsx +39 -22
  4. package/app/admin/pages/page.tsx +13 -8
  5. package/app/admin/projects/page.tsx +17 -8
  6. package/app/api/admin/assets/register/route.ts +51 -14
  7. package/app/api/admin/assets/registry/route.ts +4 -1
  8. package/app/api/admin/assets/relink/confirm/route.ts +4 -1
  9. package/app/api/admin/assets/relink/route.ts +4 -1
  10. package/app/api/admin/assets/scan/route.ts +4 -1
  11. package/app/api/admin/backups/restore-data/route.ts +4 -1
  12. package/app/api/admin/r2/connect/route.ts +4 -1
  13. package/app/api/admin/r2/delete/route.ts +4 -1
  14. package/app/api/admin/r2/rename/route.ts +4 -1
  15. package/app/api/admin/r2/upload-url/route.ts +4 -1
  16. package/app/api/admin/revalidate/route.ts +4 -1
  17. package/app/api/admin/storage/switch/route.ts +4 -1
  18. package/app/api/custom-sections/[id]/route.ts +5 -6
  19. package/components/admin/PublishToggle.tsx +2 -2
  20. package/components/admin/nav-builder/NavGridItem.tsx +4 -2
  21. package/components/admin/nav-builder/NavSettingsFields.tsx +10 -6
  22. package/components/admin/styles/ColorsEditor.tsx +7 -6
  23. package/components/admin/styles/FontsEditor.tsx +3 -1
  24. package/components/blocks/CoverSectionRenderer.tsx +7 -1
  25. package/components/blocks/SectionV2Renderer.tsx +8 -1
  26. package/components/builder/BubbleIcons.tsx +14 -0
  27. package/components/builder/CanvasMinimap.tsx +66 -49
  28. package/components/builder/CanvasToolbar.tsx +31 -41
  29. package/components/builder/SectionEditorBar.tsx +4 -2
  30. package/components/builder/SectionTypePicker.tsx +4 -2
  31. package/components/builder/SectionV2Column.tsx +13 -1
  32. package/components/builder/SettingsPanel.tsx +21 -17
  33. package/components/builder/SortableBlock.tsx +2 -2
  34. package/components/builder/SortableRow.tsx +6 -9
  35. package/components/builder/VirtualAssetGrid.tsx +8 -2
  36. package/components/builder/asset-browser/R2BrowserContent.tsx +8 -4
  37. package/components/builder/color-picker/EyedropperButton.tsx +7 -6
  38. package/components/builder/color-picker/SwatchBar.tsx +11 -6
  39. package/components/builder/color-picker/UnifiedColorPicker.tsx +11 -6
  40. package/components/builder/editors/ImageGridBlockEditor.tsx +4 -2
  41. package/components/builder/editors/MarqueeBlockEditor.tsx +3 -2
  42. package/components/builder/editors/ProjectGridEditor.tsx +12 -7
  43. package/components/builder/editors/SpacerBlockEditor.tsx +25 -23
  44. package/components/builder/editors/TextBlockEditor.tsx +19 -14
  45. package/components/builder/editors/shared.tsx +4 -2
  46. package/components/builder/live-preview/LiveImagePreview.tsx +3 -1
  47. package/components/builder/live-preview/ProjectCardWrapper.tsx +3 -1
  48. package/components/builder/live-preview/RichTextBubbleMenu.tsx +10 -6
  49. package/components/builder/live-preview/shared.tsx +5 -2
  50. package/components/builder/settings-panel/BlockLayoutTab.tsx +4 -2
  51. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +242 -0
  52. package/components/builder/settings-panel/CoverSectionSettings.tsx +4 -2
  53. package/components/builder/settings-panel/SectionV2Settings.tsx +13 -8
  54. package/components/builder/settings-panel/index.ts +1 -0
  55. package/components/ui/NavContentLightbox.tsx +41 -4
  56. package/lib/builder/serializer/normalizers.ts +14 -0
  57. package/lib/builder/serializer/serializers.ts +27 -0
  58. package/lib/builder/store-blocks.ts +15 -5
  59. package/lib/builder/store-cover.ts +16 -6
  60. package/lib/builder/store-sections.ts +151 -51
  61. package/lib/builder/types-slices.ts +14 -0
  62. package/lib/sanity/queries.ts +48 -0
  63. package/lib/sanity/types.ts +14 -0
  64. package/lib/version.ts +1 -1
  65. package/package.json +7 -5
  66. package/sanity/schemas/objects/coverSection.ts +32 -0
  67. package/sanity/schemas/objects/parallaxSlide.ts +32 -0
  68. package/sanity/schemas/pageSectionV2.ts +32 -0
package/README.md CHANGED
@@ -12,8 +12,33 @@ Andami is a self-hosted, code-first alternative to Webflow / Framer / Semplice.
12
12
  ## Demo
13
13
 
14
14
  🔗 **Live site:** [morphika.tv](https://morphika.tv)
15
- 🔗 **Admin preview:** _(add screenshot/GIF here — `docs/media/admin-preview.png`)_
16
- 🔗 **Builder canvas:** _(add screenshot here — `docs/media/builder-canvas.png`)_
15
+
16
+ ### The visual builder
17
+
18
+ ![Builder canvas with multi-viewport preview, minimap and settings panel](docs/media/builder-canvas.png)
19
+
20
+ *Infinite canvas with desktop / tablet / phone previews side-by-side, live minimap, and a contextual settings panel. Zoom, pan, undo/redo — all client-side.*
21
+
22
+ ### Adding content
23
+
24
+ <table>
25
+ <tr>
26
+ <td width="50%">
27
+ <img src="docs/media/add-block-modal.png" alt="Add Block modal" />
28
+ <p align="center"><em>8 content blocks — Text, Image, Image Grid, Video, Spacer, Button, Before/After, Audio</em></p>
29
+ </td>
30
+ <td width="50%">
31
+ <img src="docs/media/add-section-modal.png" alt="Add Section modal" />
32
+ <p align="center"><em>6 section types + reusable Custom Sections</em></p>
33
+ </td>
34
+ </tr>
35
+ </table>
36
+
37
+ ### Contextual toolbars
38
+
39
+ ![Section, column and block floating toolbars](docs/media/section-toolbar.png)
40
+
41
+ *Sections, columns and blocks each get their own floating toolbar — duplicate, reorder, delete, and add adjacent elements without losing canvas focus.*
17
42
 
18
43
  ---
19
44
 
@@ -96,28 +96,37 @@ function NavIcon({ icon }: { icon: string }) {
96
96
  const size = 20;
97
97
  switch (icon) {
98
98
  case "file":
99
+ // Tabler file-code (outline). Bumped to 22px — the file silhouette
100
+ // has empty space around the `< >` symbol, so it reads smaller than
101
+ // the other icons at the default 20px. Outline keeps it consistent
102
+ // with the rest of the sidebar.
99
103
  return (
100
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
101
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
102
- <path d="M14 2v6h6" />
104
+ <svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
105
+ <path d="M14 3v4a1 1 0 0 0 1 1h4" />
106
+ <path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
107
+ <path d="M10 13l-1 2l1 2" />
108
+ <path d="M14 13l1 2l-1 2" />
103
109
  </svg>
104
110
  );
105
111
  case "film":
112
+ // Tray / archive — stack of items going into a container.
106
113
  return (
107
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
108
- <rect x="3" y="3" width="7" height="7" rx="1.5" />
109
- <rect x="14" y="3" width="7" height="7" rx="1.5" />
110
- <rect x="3" y="14" width="7" height="7" rx="1.5" />
111
- <rect x="14" y="14" width="7" height="7" rx="1.5" />
114
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
115
+ <path d="M6.75 3.75H17.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
116
+ <path d="M4.75 7.25H19.25" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
117
+ <path d="M19.1556 10.75H4.84441C3.53306 10.75 2.57653 11.9908 2.91026 13.259L4.16144 18.0135C4.50827 19.3314 5.69986 20.25 7.06267 20.25H16.9373C18.3001 20.25 19.4917 19.3314 19.8386 18.0135L21.0897 13.259C21.4235 11.9908 20.4669 10.75 19.1556 10.75Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
112
118
  </svg>
113
119
  );
114
120
  case "palette":
121
+ // Tabler color-swatch — geometric, matches the outline style of
122
+ // the rest of the sidebar.
115
123
  return (
116
124
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
117
- <circle cx="12" cy="12" r="9" />
118
- <circle cx="8" cy="10" r="1" />
119
- <circle cx="16" cy="10" r="1" />
120
- <circle cx="12" cy="15" r="1" />
125
+ <path stroke="none" d="M0 0h24v24H0z" fill="none" />
126
+ <path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" />
127
+ <path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" />
128
+ <path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" />
129
+ <path d="M17 17l0 .01" />
121
130
  </svg>
122
131
  );
123
132
  case "nav":
@@ -146,10 +155,13 @@ function NavIcon({ icon }: { icon: string }) {
146
155
  </svg>
147
156
  );
148
157
  case "code":
158
+ // Tag — reads as "title, description, OG image, SEO", which is
159
+ // what this page actually edits (the old chevrons looked like
160
+ // "developer tool").
149
161
  return (
150
162
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
151
- <polyline points="8 6 3 12 8 18" />
152
- <polyline points="16 6 21 12 16 18" />
163
+ <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
164
+ <circle cx="7" cy="7" r="1" fill="currentColor" stroke="none" />
153
165
  </svg>
154
166
  );
155
167
  case "cloud-download":
@@ -32,6 +32,7 @@ import ParallaxGroupCanvas from "../../../../components/builder/ParallaxGroupCan
32
32
  import CoverSectionCanvas from "../../../../components/builder/CoverSectionCanvas";
33
33
  import { ThumbStatusProvider } from "../../../../lib/contexts/ThumbStatusContext";
34
34
  import PublishToggle from "../../../../components/admin/PublishToggle";
35
+ import { BubbleTooltip } from "../../../../components/builder/BubbleIcons";
35
36
 
36
37
  // ============================================
37
38
  // Preview helper — opens the page in a new tab
@@ -532,43 +533,55 @@ export default function PageEditorPage() {
532
533
  </Link>
533
534
  <span className="text-neutral-200">|</span>
534
535
 
535
- {/* Undo / Redo */}
536
+ {/* Undo / Redo — tabler arrow-back-up (Redo = mirrored via scaleX) */}
536
537
  <button
537
538
  onClick={() => store.undo()}
538
539
  disabled={!store.canUndo()}
539
- className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
540
- title="Undo (Ctrl+Z)"
540
+ className="group/bb relative rounded-lg p-1.5 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
541
+ aria-label="Undo"
541
542
  >
542
-
543
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
544
+ <path d="M9 14l-4 -4l4 -4" />
545
+ <path d="M5 10h11a4 4 0 1 1 0 8h-1" />
546
+ </svg>
547
+ <BubbleTooltip>Undo <span className="ml-1 text-white/55">Ctrl+Z</span></BubbleTooltip>
543
548
  </button>
544
549
  <button
545
550
  onClick={() => store.redo()}
546
551
  disabled={!store.canRedo()}
547
- className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
548
- title="Redo (Ctrl+Shift+Z)"
552
+ className="group/bb relative rounded-lg p-1.5 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
553
+ aria-label="Redo"
549
554
  >
550
-
555
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ transform: "scaleX(-1)" }}>
556
+ <path d="M9 14l-4 -4l4 -4" />
557
+ <path d="M5 10h11a4 4 0 1 1 0 8h-1" />
558
+ </svg>
559
+ <BubbleTooltip>Redo <span className="ml-1 text-white/55">Ctrl+Shift+Z</span></BubbleTooltip>
551
560
  </button>
552
561
 
553
- {/* Help */}
562
+ {/* Keyboard shortcuts — tabler option (Mac ⌥ glyph) */}
554
563
  <button
555
564
  onClick={() => setShowHelp(true)}
556
- className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
557
- title="Keyboard shortcuts (?)"
565
+ className="group/bb relative rounded-lg p-1.5 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
566
+ aria-label="Keyboard shortcuts"
558
567
  >
559
- ?
568
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
569
+ <path d="M14 6h5m0 12h-5l-5 -12h-4" />
570
+ </svg>
571
+ <BubbleTooltip>Keyboard shortcuts <span className="ml-1 text-white/55">?</span></BubbleTooltip>
560
572
  </button>
561
573
 
562
- {/* Page settings gear */}
574
+ {/* Page settings — tabler settings (gear with 8 lobes) */}
563
575
  <button
564
576
  onClick={() => store.clearSelection()}
565
- className="rounded-lg px-2 py-1 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
566
- title="Page settings"
577
+ className="group/bb relative rounded-lg p-1.5 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
578
+ aria-label="Page settings"
567
579
  >
568
580
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
569
- <circle cx="12" cy="12" r="3" />
570
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
581
+ <path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065" />
582
+ <path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
571
583
  </svg>
584
+ <BubbleTooltip>Page settings</BubbleTooltip>
572
585
  </button>
573
586
  <span className="text-neutral-200">|</span>
574
587
  </div>
@@ -596,8 +609,8 @@ export default function PageEditorPage() {
596
609
  {/* Preview in new tab */}
597
610
  <button
598
611
  onClick={() => openPreview(store)}
599
- className="flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 transition-colors"
600
- title="Open page preview in new tab"
612
+ className="group/bb relative flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 transition-colors"
613
+ aria-label="Open page preview in new tab"
601
614
  >
602
615
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0">
603
616
  <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
@@ -605,6 +618,7 @@ export default function PageEditorPage() {
605
618
  <line x1="10" y1="14" x2="21" y2="3" />
606
619
  </svg>
607
620
  Preview
621
+ <BubbleTooltip>Open preview in new tab</BubbleTooltip>
608
622
  </button>
609
623
 
610
624
  <button
@@ -638,9 +652,6 @@ export default function PageEditorPage() {
638
652
  ) : store.rows.length === 0 ? (
639
653
  /* Empty state */
640
654
  <div className="flex flex-col items-center justify-center h-full min-h-[400px] border border-dashed border-neutral-300 rounded-lg py-20">
641
- <div className="w-12 h-12 rounded-full border border-neutral-300 flex items-center justify-center mb-4">
642
- <span className="text-neutral-400 text-lg">+</span>
643
- </div>
644
655
  <p className="text-sm text-neutral-500 mb-2">
645
656
  This page has no content yet
646
657
  </p>
@@ -652,7 +663,13 @@ export default function PageEditorPage() {
652
663
  e.stopPropagation();
653
664
  setShowSectionPicker(true);
654
665
  }}
655
- className="rounded-lg bg-[#3580f9] px-4 py-2 text-xs text-white hover:bg-[#2d6dd4] transition-colors"
666
+ className="rounded-full text-[10px] font-medium transition-all hover:scale-105"
667
+ style={{
668
+ padding: "5px 16px",
669
+ background: "#e0daff",
670
+ color: "#7500d5",
671
+ border: "1.5px dashed #7500d5",
672
+ }}
656
673
  >
657
674
  + Add First Section
658
675
  </button>
@@ -7,6 +7,7 @@ import { csrfHeaders } from "../../../lib/csrf-client";
7
7
  import type { PageListItem } from "../../../lib/sanity/types";
8
8
  import PublishToggle from "../../../components/admin/PublishToggle";
9
9
  import { EditIcon, DuplicateIcon, DeleteIcon, PreviewIcon } from "../../../components/admin/icons";
10
+ import { BubbleTooltip } from "../../../components/builder/BubbleIcons";
10
11
 
11
12
  function HomeIcon({ active }: { active: boolean }) {
12
13
  return (
@@ -545,33 +546,37 @@ export default function AdminPagesPage() {
545
546
  <div className="flex items-center gap-1 justify-end">
546
547
  <button
547
548
  onClick={(e) => { e.stopPropagation(); setEditingPage(page); }}
548
- className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
549
- title="Edit settings"
549
+ className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
550
+ aria-label="Edit settings"
550
551
  >
551
552
  <EditIcon />
553
+ <BubbleTooltip>Edit settings</BubbleTooltip>
552
554
  </button>
553
555
  <button
554
556
  onClick={(e) => { e.stopPropagation(); handleDuplicate(page); }}
555
557
  disabled={duplicating === page._id}
556
- className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-30"
557
- title="Duplicate"
558
+ className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-30"
559
+ aria-label="Duplicate"
558
560
  >
559
561
  <DuplicateIcon />
562
+ <BubbleTooltip>Duplicate</BubbleTooltip>
560
563
  </button>
561
564
  <button
562
565
  onClick={(e) => { e.stopPropagation(); setDeletingPage(page); }}
563
- className="p-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors"
564
- title="Delete"
566
+ className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors"
567
+ aria-label="Delete"
565
568
  >
566
569
  <DeleteIcon />
570
+ <BubbleTooltip>Delete</BubbleTooltip>
567
571
  </button>
568
572
  <Link
569
573
  href={page.is_home ? "/" : `/${page.slug.current}`}
570
574
  target="_blank"
571
- className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
572
- title="Preview"
575
+ className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
576
+ aria-label="Preview"
573
577
  >
574
578
  <PreviewIcon />
579
+ <BubbleTooltip>Preview</BubbleTooltip>
575
580
  </Link>
576
581
  </div>
577
582
  </div>
@@ -9,6 +9,7 @@ import { csrfHeaders } from "../../../lib/csrf-client";
9
9
  import AssetBrowser from "../../../components/builder/AssetBrowser";
10
10
  import PublishToggle from "../../../components/admin/PublishToggle";
11
11
  import { EditIcon, DuplicateIcon, DeleteIcon, PreviewIcon } from "../../../components/admin/icons";
12
+ import { BubbleTooltip } from "../../../components/builder/BubbleIcons";
12
13
 
13
14
  // ============================================
14
15
  // Helpers
@@ -577,17 +578,21 @@ export default function AdminProjectsPage() {
577
578
  </span>
578
579
 
579
580
  <div className="flex items-center gap-1 justify-end" onClick={(e) => e.stopPropagation()}>
580
- <button onClick={() => setEditingProject(project)} className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors" title="Edit">
581
+ <button onClick={() => setEditingProject(project)} className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors" aria-label="Edit">
581
582
  <EditIcon />
583
+ <BubbleTooltip>Edit</BubbleTooltip>
582
584
  </button>
583
- <button onClick={() => handleDuplicate(project)} disabled={duplicating === project._id} className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-30" title="Duplicate">
585
+ <button onClick={() => handleDuplicate(project)} disabled={duplicating === project._id} className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-30" aria-label="Duplicate">
584
586
  <DuplicateIcon />
587
+ <BubbleTooltip>Duplicate</BubbleTooltip>
585
588
  </button>
586
- <button onClick={() => setDeletingProject(project)} className="p-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors" title="Delete">
589
+ <button onClick={() => setDeletingProject(project)} className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 transition-colors" aria-label="Delete">
587
590
  <DeleteIcon />
591
+ <BubbleTooltip>Delete</BubbleTooltip>
588
592
  </button>
589
- <a href={`/work/${project.slug.current}`} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors" title="Preview">
593
+ <a href={`/work/${project.slug.current}`} target="_blank" rel="noopener noreferrer" className="group/bb relative p-1.5 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors" aria-label="Preview">
590
594
  <PreviewIcon />
595
+ <BubbleTooltip>Preview</BubbleTooltip>
591
596
  </a>
592
597
  </div>
593
598
  </div>
@@ -628,17 +633,21 @@ export default function AdminProjectsPage() {
628
633
  {/* Hover overlay with actions */}
629
634
  <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-start justify-end p-2 opacity-0 group-hover:opacity-100">
630
635
  <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
631
- <button onClick={() => setEditingProject(project)} className="p-1.5 rounded bg-white/90 text-neutral-600 hover:text-[#3580f9] transition-colors shadow-sm" title="Edit">
636
+ <button onClick={() => setEditingProject(project)} className="group/bb relative p-1.5 rounded bg-white/90 text-neutral-600 hover:text-[#3580f9] transition-colors shadow-sm" aria-label="Edit">
632
637
  <EditIcon />
638
+ <BubbleTooltip>Edit</BubbleTooltip>
633
639
  </button>
634
- <button onClick={() => handleDuplicate(project)} disabled={duplicating === project._id} className="p-1.5 rounded bg-white/90 text-neutral-600 hover:text-neutral-900 transition-colors shadow-sm" title="Duplicate">
640
+ <button onClick={() => handleDuplicate(project)} disabled={duplicating === project._id} className="group/bb relative p-1.5 rounded bg-white/90 text-neutral-600 hover:text-neutral-900 transition-colors shadow-sm" aria-label="Duplicate">
635
641
  <DuplicateIcon />
642
+ <BubbleTooltip>Duplicate</BubbleTooltip>
636
643
  </button>
637
- <button onClick={() => setDeletingProject(project)} className="p-1.5 rounded bg-white/90 text-neutral-600 hover:text-red-500 transition-colors shadow-sm" title="Delete">
644
+ <button onClick={() => setDeletingProject(project)} className="group/bb relative p-1.5 rounded bg-white/90 text-neutral-600 hover:text-red-500 transition-colors shadow-sm" aria-label="Delete">
638
645
  <DeleteIcon />
646
+ <BubbleTooltip>Delete</BubbleTooltip>
639
647
  </button>
640
- <a href={`/work/${project.slug.current}`} target="_blank" rel="noopener noreferrer" className="p-1.5 rounded bg-white/90 text-neutral-600 hover:text-neutral-900 transition-colors shadow-sm" title="Preview">
648
+ <a href={`/work/${project.slug.current}`} target="_blank" rel="noopener noreferrer" className="group/bb relative p-1.5 rounded bg-white/90 text-neutral-600 hover:text-neutral-900 transition-colors shadow-sm" aria-label="Preview">
641
649
  <PreviewIcon />
650
+ <BubbleTooltip>Preview</BubbleTooltip>
642
651
  </a>
643
652
  </div>
644
653
  </div>
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { isAdminAuthenticated } from "../../../../../lib/auth";
3
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
3
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } 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
6
  import { adminClient as client } from "../../../../../lib/sanity/client";
@@ -28,6 +28,9 @@ export async function POST(request: NextRequest) {
28
28
  if (!validateCsrf(request)) {
29
29
  return csrfErrorResponse();
30
30
  }
31
+ if (!hasJsonContentType(request)) {
32
+ return contentTypeErrorResponse();
33
+ }
31
34
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
32
35
  return jsonError("Request body too large", 413);
33
36
  }
@@ -64,14 +67,24 @@ export async function POST(request: NextRequest) {
64
67
  typeof fileSize === "number" && fileSize > 0 ? fileSize : undefined;
65
68
 
66
69
  // ── Fetch current registry ──
67
- const registry = await client.fetch(
68
- `*[_type == "assetRegistry"][0]{ _id, assets, storage_provider }`
70
+ // Capture `_rev` so the patch below can use optimistic concurrency
71
+ // (ifRevisionId). A concurrent writer (scan, relink, another register)
72
+ // will make the commit fail with 409 instead of silently overwriting.
73
+ const registry = await client.fetch<{
74
+ _id: string;
75
+ _rev?: string;
76
+ assets?: Array<Record<string, unknown>>;
77
+ storage_provider?: string;
78
+ } | null>(
79
+ `*[_type == "assetRegistry"][0]{ _id, _rev, assets, storage_provider }`
69
80
  );
70
81
 
71
82
  if (!registry) {
72
83
  return jsonError("Asset registry not found. Scan your storage first.", 404);
73
84
  }
74
85
 
86
+ const registryRev: string | undefined = registry._rev;
87
+
75
88
  // #20: Validate that R2 is the active provider for R2-uploaded assets
76
89
  const activeProvider = registry.storage_provider || "r2";
77
90
  if (activeProvider !== "r2") {
@@ -113,12 +126,24 @@ export async function POST(request: NextRequest) {
113
126
 
114
127
  // Replace the asset at the existing index
115
128
  const patchKey = existing._key as string;
116
- await writeClient
117
- .patch(registry._id)
118
- .set({
119
- [`assets[_key=="${patchKey}"]`]: updatedAsset,
120
- })
121
- .commit();
129
+ const patch = writeClient.patch(registry._id);
130
+ if (registryRev) patch.ifRevisionId(registryRev);
131
+ try {
132
+ await patch
133
+ .set({
134
+ [`assets[_key=="${patchKey}"]`]: updatedAsset,
135
+ })
136
+ .commit();
137
+ } catch (commitErr) {
138
+ const msg = commitErr instanceof Error ? commitErr.message : "";
139
+ if (msg.includes("rev mismatch") || msg.includes("409")) {
140
+ return jsonError(
141
+ "Registry was modified by another request. Please retry.",
142
+ 409
143
+ );
144
+ }
145
+ throw commitErr;
146
+ }
122
147
 
123
148
  auditLog("asset.register.update", { path: key, fileSize: size });
124
149
 
@@ -142,11 +167,23 @@ export async function POST(request: NextRequest) {
142
167
  last_checked_at: now,
143
168
  };
144
169
 
145
- await writeClient
146
- .patch(registry._id)
147
- .setIfMissing({ assets: [] })
148
- .append("assets", [newAsset])
149
- .commit();
170
+ const patch = writeClient.patch(registry._id);
171
+ if (registryRev) patch.ifRevisionId(registryRev);
172
+ try {
173
+ await patch
174
+ .setIfMissing({ assets: [] })
175
+ .append("assets", [newAsset])
176
+ .commit();
177
+ } catch (commitErr) {
178
+ const msg = commitErr instanceof Error ? commitErr.message : "";
179
+ if (msg.includes("rev mismatch") || msg.includes("409")) {
180
+ return jsonError(
181
+ "Registry was modified by another request. Please retry.",
182
+ 409
183
+ );
184
+ }
185
+ throw commitErr;
186
+ }
150
187
 
151
188
  auditLog("asset.register.create", { path: key, fileSize: size });
152
189
 
@@ -3,7 +3,7 @@ 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";
6
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
6
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
7
7
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../../lib/security";
8
8
  import { logger } from "../../../../../lib/logger";
9
9
 
@@ -50,6 +50,9 @@ export async function POST(request: NextRequest) {
50
50
  if (!validateCsrf(request)) {
51
51
  return csrfErrorResponse();
52
52
  }
53
+ if (!hasJsonContentType(request)) {
54
+ return contentTypeErrorResponse();
55
+ }
53
56
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
54
57
  return NextResponse.json({ error: "Request body too large" }, { status: 413 });
55
58
  }
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
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
- import { validateCsrf, csrfErrorResponse } from "../../../../../../lib/csrf";
5
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../../lib/csrf";
6
6
  import { logger } from "../../../../../../lib/logger";
7
7
 
8
8
  /**
@@ -74,6 +74,9 @@ export async function POST(request: NextRequest) {
74
74
  if (!validateCsrf(request)) {
75
75
  return csrfErrorResponse();
76
76
  }
77
+ if (!hasJsonContentType(request)) {
78
+ return contentTypeErrorResponse();
79
+ }
77
80
 
78
81
  try {
79
82
  const body = await request.json();
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { adminClient as client } from "../../../../../lib/sanity/client";
3
3
  import { isAdminAuthenticated } from "../../../../../lib/auth";
4
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
5
5
  import { getStorageAdapter } from "../../../../../lib/storage";
6
6
  import { logger } from "../../../../../lib/logger";
7
7
  import type { RegisteredAsset } from "../../../../../lib/sanity/types";
@@ -39,6 +39,9 @@ export async function POST(request: NextRequest) {
39
39
  if (!validateCsrf(request)) {
40
40
  return csrfErrorResponse();
41
41
  }
42
+ if (!hasJsonContentType(request)) {
43
+ return contentTypeErrorResponse();
44
+ }
42
45
 
43
46
  try {
44
47
  const body = await request.json();
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
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
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
5
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
6
6
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../../lib/security";
7
7
  import { getStorageAdapter, getActiveProvider } from "../../../../../lib/storage";
8
8
  import { logger } from "../../../../../lib/logger";
@@ -29,6 +29,9 @@ export async function POST(request: NextRequest) {
29
29
  if (!validateCsrf(request)) {
30
30
  return csrfErrorResponse();
31
31
  }
32
+ if (!hasJsonContentType(request)) {
33
+ return contentTypeErrorResponse();
34
+ }
32
35
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
33
36
  return NextResponse.json({ error: "Request body too large" }, { status: 413 });
34
37
  }
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { isAdminAuthenticated } from "../../../../../lib/auth";
3
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
3
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
4
4
  import {
5
5
  checkRateLimit,
6
6
  jsonError,
@@ -92,6 +92,9 @@ export async function POST(request: NextRequest) {
92
92
  if (!validateCsrf(request)) {
93
93
  return csrfErrorResponse();
94
94
  }
95
+ if (!hasJsonContentType(request)) {
96
+ return contentTypeErrorResponse();
97
+ }
95
98
 
96
99
  // M-2: reject oversize bodies up front so we don't spend CPU on
97
100
  // `request.json()` for a payload we'll refuse anyway. Vercel's platform
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { S3Client, HeadBucketCommand, PutBucketCorsCommand } from "@aws-sdk/client-s3";
3
3
  import { isAdminAuthenticated } from "../../../../../lib/auth";
4
4
  import { writeClient } from "../../../../../lib/sanity/writeClient";
5
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
5
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
6
6
  import { encryptToken, isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
7
7
  import { logger } from "../../../../../lib/logger";
8
8
  import { invalidateProviderConfigCache } from "../../../../../lib/storage";
@@ -25,6 +25,9 @@ export async function POST(request: NextRequest) {
25
25
  if (!validateCsrf(request)) {
26
26
  return csrfErrorResponse();
27
27
  }
28
+ if (!hasJsonContentType(request)) {
29
+ return contentTypeErrorResponse();
30
+ }
28
31
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
29
32
  return jsonError("Request body too large", 413);
30
33
  }
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { S3Client, DeleteObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } from "@aws-sdk/client-s3";
3
3
  import { isAdminAuthenticated } from "../../../../../lib/auth";
4
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
5
5
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, checkRateLimit } from "../../../../../lib/security";
6
6
  import { adminClient as client } from "../../../../../lib/sanity/client";
7
7
  import { writeClient } from "../../../../../lib/sanity/writeClient";
@@ -27,6 +27,9 @@ export async function POST(request: NextRequest) {
27
27
  if (!validateCsrf(request)) {
28
28
  return csrfErrorResponse();
29
29
  }
30
+ if (!hasJsonContentType(request)) {
31
+ return contentTypeErrorResponse();
32
+ }
30
33
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
31
34
  return jsonError("Request body too large", 413);
32
35
  }
@@ -1,7 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { S3Client, CopyObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } from "@aws-sdk/client-s3";
3
3
  import { isAdminAuthenticated } from "../../../../../lib/auth";
4
- import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import { validateCsrf, csrfErrorResponse, hasJsonContentType, contentTypeErrorResponse } from "../../../../../lib/csrf";
5
5
  import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath, checkRateLimit } from "../../../../../lib/security";
6
6
  import { adminClient as client } from "../../../../../lib/sanity/client";
7
7
  import { writeClient } from "../../../../../lib/sanity/writeClient";
@@ -29,6 +29,9 @@ export async function POST(request: NextRequest) {
29
29
  if (!validateCsrf(request)) {
30
30
  return csrfErrorResponse();
31
31
  }
32
+ if (!hasJsonContentType(request)) {
33
+ return contentTypeErrorResponse();
34
+ }
32
35
  if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
33
36
  return jsonError("Request body too large", 413);
34
37
  }