@morphika/andami 0.5.1 → 0.5.3

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 (147) hide show
  1. package/README.md +27 -2
  2. package/app/admin/assets/page.tsx +6 -6
  3. package/app/admin/database/page.tsx +302 -302
  4. package/app/admin/error.tsx +53 -53
  5. package/app/admin/layout.tsx +332 -320
  6. package/app/admin/navigation/page.tsx +255 -255
  7. package/app/admin/pages/[slug]/page.tsx +44 -27
  8. package/app/admin/pages/page.tsx +24 -19
  9. package/app/admin/projects/page.tsx +30 -21
  10. package/app/admin/setup/page.tsx +1 -1
  11. package/app/admin/styles/page.tsx +1 -1
  12. package/app/api/admin/assets/register/route.ts +51 -14
  13. package/app/api/admin/assets/registry/route.ts +4 -1
  14. package/app/api/admin/assets/relink/confirm/route.ts +4 -1
  15. package/app/api/admin/assets/relink/route.ts +4 -1
  16. package/app/api/admin/assets/scan/route.ts +4 -1
  17. package/app/api/admin/backups/restore-data/route.ts +4 -1
  18. package/app/api/admin/r2/connect/route.ts +4 -1
  19. package/app/api/admin/r2/delete/route.ts +4 -1
  20. package/app/api/admin/r2/rename/route.ts +4 -1
  21. package/app/api/admin/r2/upload-url/route.ts +4 -1
  22. package/app/api/admin/revalidate/route.ts +4 -1
  23. package/app/api/admin/storage/switch/route.ts +4 -1
  24. package/app/api/custom-sections/[id]/route.ts +5 -6
  25. package/components/admin/MetadataEditor.tsx +6 -6
  26. package/components/admin/PublishToggle.tsx +2 -2
  27. package/components/admin/nav-builder/NavBuilder.tsx +1 -1
  28. package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
  29. package/components/admin/nav-builder/NavGridCell.tsx +48 -48
  30. package/components/admin/nav-builder/NavGridItem.tsx +8 -6
  31. package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
  32. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
  33. package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
  34. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
  35. package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
  36. package/components/admin/nav-builder/NavSettingsFields.tsx +518 -514
  37. package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
  38. package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
  39. package/components/admin/setup-wizard/DoneStep.tsx +1 -1
  40. package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
  41. package/components/admin/setup-wizard/StorageStep.tsx +2 -2
  42. package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
  43. package/components/admin/styles/ColorsEditor.tsx +9 -8
  44. package/components/admin/styles/FontsEditor.tsx +9 -7
  45. package/components/admin/styles/GridLayoutEditor.tsx +9 -9
  46. package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
  47. package/components/admin/styles/TypographyEditor.tsx +6 -6
  48. package/components/admin/styles/shared.tsx +68 -68
  49. package/components/blocks/AudioBlockRenderer.tsx +286 -286
  50. package/components/blocks/CoverSectionRenderer.tsx +7 -1
  51. package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
  52. package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
  53. package/components/blocks/SectionV2Renderer.tsx +8 -1
  54. package/components/builder/BlockCardIcons.tsx +316 -316
  55. package/components/builder/BlockTypePicker.tsx +1 -1
  56. package/components/builder/BubbleIcons.tsx +104 -0
  57. package/components/builder/BuilderCanvas.tsx +2 -0
  58. package/components/builder/CanvasMinimap.tsx +66 -49
  59. package/components/builder/CanvasToolbar.tsx +31 -41
  60. package/components/builder/CoverSectionCanvas.tsx +363 -363
  61. package/components/builder/DeviceFrame.tsx +1 -1
  62. package/components/builder/DndWrapper.tsx +3 -3
  63. package/components/builder/InsertionLines.tsx +1 -1
  64. package/components/builder/SectionCardIcons.tsx +421 -320
  65. package/components/builder/SectionEditorBar.tsx +5 -3
  66. package/components/builder/SectionTypePicker.tsx +7 -5
  67. package/components/builder/SectionV2Canvas.tsx +1 -1
  68. package/components/builder/SectionV2Column.tsx +82 -68
  69. package/components/builder/SettingsPanel.tsx +21 -17
  70. package/components/builder/SortableBlock.tsx +93 -73
  71. package/components/builder/SortableRow.tsx +33 -35
  72. package/components/builder/VirtualAssetGrid.tsx +10 -4
  73. package/components/builder/asset-browser/R2BrowserContent.tsx +18 -14
  74. package/components/builder/blockStyles.tsx +192 -185
  75. package/components/builder/color-picker/AlphaSlider.tsx +141 -141
  76. package/components/builder/color-picker/ColorInputs.tsx +105 -105
  77. package/components/builder/color-picker/EyedropperButton.tsx +75 -74
  78. package/components/builder/color-picker/HueSlider.tsx +124 -124
  79. package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
  80. package/components/builder/color-picker/SwatchBar.tsx +98 -93
  81. package/components/builder/color-picker/UnifiedColorPicker.tsx +11 -6
  82. package/components/builder/editors/AudioBlockEditor.tsx +242 -242
  83. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
  84. package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
  85. package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
  86. package/components/builder/editors/HoverEffectPicker.tsx +2 -2
  87. package/components/builder/editors/ImageBlockEditor.tsx +2 -2
  88. package/components/builder/editors/ImageGridBlockEditor.tsx +8 -6
  89. package/components/builder/editors/MarqueeBlockEditor.tsx +622 -0
  90. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
  91. package/components/builder/editors/ProjectGridEditor.tsx +21 -16
  92. package/components/builder/editors/SpacerBlockEditor.tsx +29 -27
  93. package/components/builder/editors/StaggerSettings.tsx +109 -109
  94. package/components/builder/editors/TextBlockEditor.tsx +22 -17
  95. package/components/builder/editors/TextStylePicker.tsx +1 -1
  96. package/components/builder/editors/VideoBlockEditor.tsx +2 -2
  97. package/components/builder/editors/index.ts +11 -10
  98. package/components/builder/editors/shared.tsx +10 -8
  99. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
  100. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
  101. package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
  102. package/components/builder/live-preview/LiveImagePreview.tsx +4 -2
  103. package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
  104. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
  105. package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
  106. package/components/builder/live-preview/ProjectCardWrapper.tsx +293 -291
  107. package/components/builder/live-preview/RichTextBubbleMenu.tsx +10 -6
  108. package/components/builder/live-preview/shared.tsx +5 -2
  109. package/components/builder/settings-panel/AnimationTab.tsx +138 -138
  110. package/components/builder/settings-panel/BlockLayoutTab.tsx +11 -9
  111. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
  112. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +242 -0
  113. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  114. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
  115. package/components/builder/settings-panel/CoverSectionSettings.tsx +337 -335
  116. package/components/builder/settings-panel/PageSettings.tsx +3 -3
  117. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  118. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
  119. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
  120. package/components/builder/settings-panel/SectionV2Settings.tsx +25 -20
  121. package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
  122. package/components/builder/settings-panel/index.ts +1 -0
  123. package/lib/animation/enter-types.ts +1 -0
  124. package/lib/animation/hover-effect-presets.ts +210 -210
  125. package/lib/animation/hover-effect-types.ts +1 -0
  126. package/lib/builder/block-registrations.ts +468 -417
  127. package/lib/builder/constants.ts +111 -111
  128. package/lib/builder/serializer/normalizers.ts +14 -0
  129. package/lib/builder/serializer/serializers.ts +27 -0
  130. package/lib/builder/store-sections.ts +23 -2
  131. package/lib/builder/types-slices.ts +428 -414
  132. package/lib/builder/types.ts +4 -1
  133. package/lib/config/index.ts +27 -27
  134. package/lib/sanity/queries.ts +48 -0
  135. package/lib/sanity/types.ts +112 -1
  136. package/lib/version.ts +1 -1
  137. package/package.json +7 -5
  138. package/sanity/schemas/blocks/audioBlock.ts +69 -69
  139. package/sanity/schemas/blocks/index.ts +12 -11
  140. package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
  141. package/sanity/schemas/index.ts +120 -117
  142. package/sanity/schemas/objects/coverSection.ts +32 -0
  143. package/sanity/schemas/objects/parallaxSlide.ts +32 -0
  144. package/sanity/schemas/pageSectionV2.ts +32 -0
  145. package/styles/admin.css +85 -85
  146. package/styles/animations.css +237 -237
  147. package/styles/base.css +114 -114
@@ -23,7 +23,7 @@ import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup, isCoverSecti
23
23
  import SectionEditorBar from "../../../../components/builder/SectionEditorBar";
24
24
  import CustomSectionInstanceCard from "../../../../components/builder/CustomSectionInstanceCard";
25
25
  import { ColumnDragProvider } from "../../../../components/builder/ColumnDragContext";
26
- import type { BlockType } from "../../../../lib/builder/types";
26
+ import type { BlockType, SectionBlockType } from "../../../../lib/builder/types";
27
27
  import { findGaps } from "../../../../lib/builder/cascade";
28
28
  import { isSectionBlockType } from "../../../../lib/builder/types";
29
29
  import BlockLivePreview from "../../../../components/builder/BlockLivePreview";
@@ -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
@@ -415,10 +416,10 @@ export default function PageEditorPage() {
415
416
  [store]
416
417
  );
417
418
 
418
- // Handle add page section (project grid / project carousel)
419
+ // Handle add page section (project grid / project carousel / marquee)
419
420
  // afterRowKey=null → always appends to end of page (bottom "Add Section" button)
420
421
  const handleAddSection = useCallback(
421
- (blockType: "projectGridBlock" | "projectCarouselBlock") => {
422
+ (blockType: SectionBlockType) => {
422
423
  store.addSection(blockType, null);
423
424
  setShowSectionPicker(false);
424
425
  },
@@ -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,12 +618,13 @@ 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
611
625
  onClick={handleSave}
612
626
  disabled={store.isSaving || !store.isDirty}
613
- className="rounded-lg bg-[#076bff] px-5 py-1.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
627
+ className="rounded-lg bg-[#3580f9] px-5 py-1.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
614
628
  >
615
629
  {store.isSaving ? "Saving..." : "Save"}
616
630
  </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-[#076bff] px-4 py-2 text-xs text-white hover:bg-[#0559d4] 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>
@@ -818,7 +835,7 @@ export default function PageEditorPage() {
818
835
  padding: "5px 16px",
819
836
  background: "#e0daff",
820
837
  color: "#7500d5",
821
- border: "1px dashed #7500d5",
838
+ border: "1.5px dashed #7500d5",
822
839
  }}
823
840
  >
824
841
  + Add Section
@@ -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 (
@@ -123,7 +124,7 @@ function CreatePageModal({
123
124
  value={title}
124
125
  onChange={(e) => setTitle(e.target.value)}
125
126
  placeholder="Page title"
126
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10"
127
+ className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10"
127
128
  autoFocus
128
129
  />
129
130
  </div>
@@ -136,7 +137,7 @@ function CreatePageModal({
136
137
  value={slug}
137
138
  onChange={(e) => { setSlug(e.target.value); setAutoSlug(false); }}
138
139
  placeholder="page-slug"
139
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10"
140
+ className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10"
140
141
  />
141
142
  <p className="text-xs text-neutral-400 mt-1">URL: /{slug || "page-slug"}</p>
142
143
  </div>
@@ -152,7 +153,7 @@ function CreatePageModal({
152
153
  <button
153
154
  type="submit"
154
155
  disabled={creating}
155
- className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-50"
156
+ className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50"
156
157
  >
157
158
  {creating ? "Creating..." : "Create"}
158
159
  </button>
@@ -297,7 +298,7 @@ function EditPageModal({
297
298
  type="text"
298
299
  value={title}
299
300
  onChange={(e) => setTitle(e.target.value)}
300
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10"
301
+ className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10"
301
302
  autoFocus
302
303
  />
303
304
  </div>
@@ -307,14 +308,14 @@ function EditPageModal({
307
308
  type="text"
308
309
  value={slug}
309
310
  onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
310
- className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10"
311
+ className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10"
311
312
  />
312
313
  <p className="text-xs text-neutral-400 mt-1">URL: /{slug || "page-slug"}</p>
313
314
  </div>
314
315
  {error && <p className="text-xs text-[var(--admin-error)]">{error}</p>}
315
316
  <div className="flex gap-3 justify-end pt-2">
316
317
  <button type="button" onClick={onClose} className="rounded-lg border border-neutral-200 px-5 py-2.5 text-sm text-neutral-500 hover:text-neutral-800 hover:border-neutral-300 transition-colors">Cancel</button>
317
- <button type="submit" disabled={saving} className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-50">{saving ? "Saving..." : "Save"}</button>
318
+ <button type="submit" disabled={saving} className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50">{saving ? "Saving..." : "Save"}</button>
318
319
  </div>
319
320
  </form>
320
321
  </div>
@@ -381,7 +382,7 @@ function HomeConfirmModal({
381
382
  {error && <p className="text-xs text-[var(--admin-error)] mb-4">{error}</p>}
382
383
  <div className="flex gap-3 justify-end">
383
384
  <button onClick={onClose} className="rounded-lg border border-neutral-200 px-5 py-2.5 text-sm text-neutral-500 hover:text-neutral-800 hover:border-neutral-300 transition-colors">Cancel</button>
384
- <button onClick={handleConfirm} disabled={setting} className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-50">{setting ? "Setting..." : "Set as Home"}</button>
385
+ <button onClick={handleConfirm} disabled={setting} className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50">{setting ? "Setting..." : "Set as Home"}</button>
385
386
  </div>
386
387
  </div>
387
388
  </div>
@@ -456,7 +457,7 @@ export default function AdminPagesPage() {
456
457
  </h1>
457
458
  <button
458
459
  onClick={() => setShowCreate(true)}
459
- className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors"
460
+ className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors"
460
461
  >
461
462
  + New Page
462
463
  </button>
@@ -469,7 +470,7 @@ export default function AdminPagesPage() {
469
470
  value={search}
470
471
  onChange={(e) => setSearch(e.target.value)}
471
472
  placeholder="Search by title or slug..."
472
- className="w-full max-w-md rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10 shadow-sm"
473
+ className="w-full max-w-md rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10 shadow-sm"
473
474
  />
474
475
  </div>
475
476
 
@@ -484,7 +485,7 @@ export default function AdminPagesPage() {
484
485
  {pages.length === 0 ? "No pages yet" : "No pages match your search"}
485
486
  </p>
486
487
  {pages.length === 0 && (
487
- <button onClick={() => setShowCreate(true)} className="text-xs text-[#076bff] hover:underline">
488
+ <button onClick={() => setShowCreate(true)} className="text-xs text-[#3580f9] hover:underline">
488
489
  Create your first page
489
490
  </button>
490
491
  )}
@@ -507,7 +508,7 @@ export default function AdminPagesPage() {
507
508
  >
508
509
  {/* Title */}
509
510
  <div className="cursor-pointer" onClick={() => router.push(`/admin/pages/${page.slug.current}`)}>
510
- <p className="text-sm text-neutral-900 group-hover:text-[#076bff] transition-colors">
511
+ <p className="text-sm text-neutral-900 group-hover:text-[#3580f9] transition-colors">
511
512
  {page.title}
512
513
  </p>
513
514
  <p className="text-xs text-neutral-400">/{page.slug.current}</p>
@@ -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
@@ -135,7 +136,7 @@ function MediaPicker({
135
136
  <button
136
137
  type="button"
137
138
  onClick={() => openBrowser()}
138
- className="w-full aspect-[4/3] rounded-lg border border-dashed border-neutral-300 flex flex-col items-center justify-center gap-2 hover:border-[#076bff] hover:bg-neutral-50 transition-colors cursor-pointer"
139
+ className="w-full aspect-[4/3] rounded-lg border border-dashed border-neutral-300 flex flex-col items-center justify-center gap-2 hover:border-[#3580f9] hover:bg-neutral-50 transition-colors cursor-pointer"
139
140
  >
140
141
  {activeTab === "image" ? (
141
142
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400">
@@ -240,13 +241,13 @@ function CreateProjectModal({
240
241
  <form onSubmit={handleSubmit} className="space-y-4">
241
242
  <div>
242
243
  <label className="block text-xs text-neutral-500 mb-1">Project Title</label>
243
- <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="My Project" className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none" autoFocus />
244
+ <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="My Project" className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none" autoFocus />
244
245
  </div>
245
246
  <div>
246
247
  <label className="block text-xs text-neutral-500 mb-1">URL Slug</label>
247
248
  <div className="flex items-center gap-1">
248
249
  <span className="text-xs text-neutral-400">/work/</span>
249
- <input type="text" value={slug} onChange={(e) => { setSlug(e.target.value); setAutoSlug(false); }} placeholder="my-project" className="flex-1 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none" />
250
+ <input type="text" value={slug} onChange={(e) => { setSlug(e.target.value); setAutoSlug(false); }} placeholder="my-project" className="flex-1 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none" />
250
251
  </div>
251
252
  </div>
252
253
  <MediaPicker
@@ -259,7 +260,7 @@ function CreateProjectModal({
259
260
  {error && <p className="text-xs text-[var(--admin-error)]">{error}</p>}
260
261
  <div className="flex gap-3 justify-end pt-2">
261
262
  <button type="button" onClick={onClose} className="rounded-lg border border-neutral-200 px-5 py-2.5 text-sm text-neutral-500 hover:text-neutral-800 hover:border-neutral-300 transition-colors">Cancel</button>
262
- <button type="submit" disabled={creating} className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-50">{creating ? "Creating..." : "Create"}</button>
263
+ <button type="submit" disabled={creating} className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50">{creating ? "Creating..." : "Create"}</button>
263
264
  </div>
264
265
  </form>
265
266
  </div>
@@ -344,13 +345,13 @@ function EditProjectModal({
344
345
  <form onSubmit={handleSave} className="space-y-4">
345
346
  <div>
346
347
  <label className="block text-xs text-neutral-500 mb-1">Project Title</label>
347
- <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none" autoFocus />
348
+ <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none" autoFocus />
348
349
  </div>
349
350
  <div>
350
351
  <label className="block text-xs text-neutral-500 mb-1">URL Slug</label>
351
352
  <div className="flex items-center gap-1">
352
353
  <span className="text-xs text-neutral-400">/work/</span>
353
- <input type="text" value={slug} onChange={(e) => setSlug(e.target.value)} className="flex-1 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none" />
354
+ <input type="text" value={slug} onChange={(e) => setSlug(e.target.value)} className="flex-1 rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none" />
354
355
  </div>
355
356
  </div>
356
357
  <MediaPicker
@@ -363,7 +364,7 @@ function EditProjectModal({
363
364
  {error && <p className="text-xs text-[var(--admin-error)]">{error}</p>}
364
365
  <div className="flex gap-3 justify-end pt-2">
365
366
  <button type="button" onClick={onClose} className="rounded-lg border border-neutral-200 px-5 py-2.5 text-sm text-neutral-500 hover:text-neutral-800 hover:border-neutral-300 transition-colors">Cancel</button>
366
- <button type="submit" disabled={saving} className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-50">{saving ? "Saving..." : "Save"}</button>
367
+ <button type="submit" disabled={saving} className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors disabled:opacity-50">{saving ? "Saving..." : "Save"}</button>
367
368
  </div>
368
369
  </form>
369
370
  </div>
@@ -494,7 +495,7 @@ export default function AdminProjectsPage() {
494
495
  <select
495
496
  value={viewMode === "thumb" ? "Thumb View" : "List View"}
496
497
  onChange={(e) => setViewMode(e.target.value === "Thumb View" ? "thumb" : "list")}
497
- className="appearance-none rounded-lg border border-neutral-200 bg-white px-3 py-2.5 pr-8 text-sm text-neutral-700 focus:border-[#076bff] focus:outline-none cursor-pointer"
498
+ className="appearance-none rounded-lg border border-neutral-200 bg-white px-3 py-2.5 pr-8 text-sm text-neutral-700 focus:border-[#3580f9] focus:outline-none cursor-pointer"
498
499
  >
499
500
  <option>List View</option>
500
501
  <option>Thumb View</option>
@@ -505,7 +506,7 @@ export default function AdminProjectsPage() {
505
506
  </div>
506
507
  <button
507
508
  onClick={() => setShowCreate(true)}
508
- className="rounded-lg bg-[#076bff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors"
509
+ className="rounded-lg bg-[#3580f9] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#2d6dd4] transition-colors"
509
510
  >
510
511
  + New Project
511
512
  </button>
@@ -519,7 +520,7 @@ export default function AdminProjectsPage() {
519
520
  value={search}
520
521
  onChange={(e) => setSearch(e.target.value)}
521
522
  placeholder="Search by title or slug..."
522
- className="w-full max-w-md rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#076bff] focus:outline-none focus:ring-2 focus:ring-[#076bff]/10 shadow-sm"
523
+ className="w-full max-w-md rounded-lg border border-neutral-200 bg-white px-3 py-2.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-[#3580f9] focus:outline-none focus:ring-2 focus:ring-[#3580f9]/10 shadow-sm"
523
524
  />
524
525
  </div>
525
526
 
@@ -534,7 +535,7 @@ export default function AdminProjectsPage() {
534
535
  {projects.length === 0 ? "No projects yet" : "No projects match your search"}
535
536
  </p>
536
537
  {projects.length === 0 && (
537
- <button onClick={() => setShowCreate(true)} className="text-xs text-[#076bff] hover:underline">
538
+ <button onClick={() => setShowCreate(true)} className="text-xs text-[#3580f9] hover:underline">
538
539
  Create your first project
539
540
  </button>
540
541
  )}
@@ -557,7 +558,7 @@ export default function AdminProjectsPage() {
557
558
  className="group grid grid-cols-[1fr_120px_140px_180px] gap-4 items-center rounded-lg bg-white px-5 py-4 hover:shadow-md transition-all cursor-pointer border border-transparent hover:border-neutral-200"
558
559
  onClick={() => router.push(`/admin/projects/${project.slug.current}`)}
559
560
  >
560
- <p className="text-sm text-neutral-900 group-hover:text-[#076bff] transition-colors">
561
+ <p className="text-sm text-neutral-900 group-hover:text-[#3580f9] transition-colors">
561
562
  {project.title}
562
563
  </p>
563
564
 
@@ -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>
@@ -601,7 +606,7 @@ export default function AdminProjectsPage() {
601
606
  <div key={project._id} className="group relative">
602
607
  {/* Thumbnail card */}
603
608
  <div
604
- className="relative aspect-[4/3] rounded-xl overflow-hidden bg-neutral-200 cursor-pointer border border-neutral-200/80 hover:shadow-md hover:shadow-[#076bff]/5 transition-all"
609
+ className="relative aspect-[4/3] rounded-xl overflow-hidden bg-neutral-200 cursor-pointer border border-neutral-200/80 hover:shadow-md hover:shadow-[#3580f9]/5 transition-all"
605
610
  onClick={() => router.push(`/admin/projects/${project.slug.current}`)}
606
611
  >
607
612
  {project.thumbnail_path ? (
@@ -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-[#076bff] 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>
@@ -53,7 +53,7 @@ export default function SetupPage() {
53
53
  style={{ fontFamily: "Inter, system-ui, sans-serif" }}
54
54
  >
55
55
  <div className="text-center">
56
- <div className="w-6 h-6 border-2 border-[#076bff] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
56
+ <div className="w-6 h-6 border-2 border-[#3580f9] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
57
57
  <p className="text-xs text-[#999]">Checking setup status...</p>
58
58
  </div>
59
59
  </div>
@@ -172,7 +172,7 @@ export default function AdminStylesPage() {
172
172
  onClick={() => setActiveTab(tab.id)}
173
173
  className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors border-b-2 -mb-px ${
174
174
  isActive
175
- ? "text-neutral-900 border-[#076bff]"
175
+ ? "text-neutral-900 border-[#3580f9]"
176
176
  : "text-neutral-400 border-transparent hover:text-neutral-600"
177
177
  }`}
178
178
  >
@@ -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