@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.
- package/README.md +27 -2
- package/app/admin/assets/page.tsx +6 -6
- package/app/admin/database/page.tsx +302 -302
- package/app/admin/error.tsx +53 -53
- package/app/admin/layout.tsx +332 -320
- package/app/admin/navigation/page.tsx +255 -255
- package/app/admin/pages/[slug]/page.tsx +44 -27
- package/app/admin/pages/page.tsx +24 -19
- package/app/admin/projects/page.tsx +30 -21
- package/app/admin/setup/page.tsx +1 -1
- package/app/admin/styles/page.tsx +1 -1
- package/app/api/admin/assets/register/route.ts +51 -14
- package/app/api/admin/assets/registry/route.ts +4 -1
- package/app/api/admin/assets/relink/confirm/route.ts +4 -1
- package/app/api/admin/assets/relink/route.ts +4 -1
- package/app/api/admin/assets/scan/route.ts +4 -1
- package/app/api/admin/backups/restore-data/route.ts +4 -1
- package/app/api/admin/r2/connect/route.ts +4 -1
- package/app/api/admin/r2/delete/route.ts +4 -1
- package/app/api/admin/r2/rename/route.ts +4 -1
- package/app/api/admin/r2/upload-url/route.ts +4 -1
- package/app/api/admin/revalidate/route.ts +4 -1
- package/app/api/admin/storage/switch/route.ts +4 -1
- package/app/api/custom-sections/[id]/route.ts +5 -6
- package/components/admin/MetadataEditor.tsx +6 -6
- package/components/admin/PublishToggle.tsx +2 -2
- package/components/admin/nav-builder/NavBuilder.tsx +1 -1
- package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
- package/components/admin/nav-builder/NavGridCell.tsx +48 -48
- package/components/admin/nav-builder/NavGridItem.tsx +8 -6
- package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
- package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
- package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
- package/components/admin/nav-builder/NavSettingsFields.tsx +518 -514
- package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
- package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
- package/components/admin/setup-wizard/DoneStep.tsx +1 -1
- package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
- package/components/admin/setup-wizard/StorageStep.tsx +2 -2
- package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
- package/components/admin/styles/ColorsEditor.tsx +9 -8
- package/components/admin/styles/FontsEditor.tsx +9 -7
- package/components/admin/styles/GridLayoutEditor.tsx +9 -9
- package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
- package/components/admin/styles/TypographyEditor.tsx +6 -6
- package/components/admin/styles/shared.tsx +68 -68
- package/components/blocks/AudioBlockRenderer.tsx +286 -286
- package/components/blocks/CoverSectionRenderer.tsx +7 -1
- package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
- package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
- package/components/blocks/SectionV2Renderer.tsx +8 -1
- package/components/builder/BlockCardIcons.tsx +316 -316
- package/components/builder/BlockTypePicker.tsx +1 -1
- package/components/builder/BubbleIcons.tsx +104 -0
- package/components/builder/BuilderCanvas.tsx +2 -0
- package/components/builder/CanvasMinimap.tsx +66 -49
- package/components/builder/CanvasToolbar.tsx +31 -41
- package/components/builder/CoverSectionCanvas.tsx +363 -363
- package/components/builder/DeviceFrame.tsx +1 -1
- package/components/builder/DndWrapper.tsx +3 -3
- package/components/builder/InsertionLines.tsx +1 -1
- package/components/builder/SectionCardIcons.tsx +421 -320
- package/components/builder/SectionEditorBar.tsx +5 -3
- package/components/builder/SectionTypePicker.tsx +7 -5
- package/components/builder/SectionV2Canvas.tsx +1 -1
- package/components/builder/SectionV2Column.tsx +82 -68
- package/components/builder/SettingsPanel.tsx +21 -17
- package/components/builder/SortableBlock.tsx +93 -73
- package/components/builder/SortableRow.tsx +33 -35
- package/components/builder/VirtualAssetGrid.tsx +10 -4
- package/components/builder/asset-browser/R2BrowserContent.tsx +18 -14
- package/components/builder/blockStyles.tsx +192 -185
- package/components/builder/color-picker/AlphaSlider.tsx +141 -141
- package/components/builder/color-picker/ColorInputs.tsx +105 -105
- package/components/builder/color-picker/EyedropperButton.tsx +75 -74
- package/components/builder/color-picker/HueSlider.tsx +124 -124
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
- package/components/builder/color-picker/SwatchBar.tsx +98 -93
- package/components/builder/color-picker/UnifiedColorPicker.tsx +11 -6
- package/components/builder/editors/AudioBlockEditor.tsx +242 -242
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
- package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
- package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
- package/components/builder/editors/HoverEffectPicker.tsx +2 -2
- package/components/builder/editors/ImageBlockEditor.tsx +2 -2
- package/components/builder/editors/ImageGridBlockEditor.tsx +8 -6
- package/components/builder/editors/MarqueeBlockEditor.tsx +622 -0
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
- package/components/builder/editors/ProjectGridEditor.tsx +21 -16
- package/components/builder/editors/SpacerBlockEditor.tsx +29 -27
- package/components/builder/editors/StaggerSettings.tsx +109 -109
- package/components/builder/editors/TextBlockEditor.tsx +22 -17
- package/components/builder/editors/TextStylePicker.tsx +1 -1
- package/components/builder/editors/VideoBlockEditor.tsx +2 -2
- package/components/builder/editors/index.ts +11 -10
- package/components/builder/editors/shared.tsx +10 -8
- package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
- package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
- package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
- package/components/builder/live-preview/LiveImagePreview.tsx +4 -2
- package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
- package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
- package/components/builder/live-preview/ProjectCardWrapper.tsx +293 -291
- package/components/builder/live-preview/RichTextBubbleMenu.tsx +10 -6
- package/components/builder/live-preview/shared.tsx +5 -2
- package/components/builder/settings-panel/AnimationTab.tsx +138 -138
- package/components/builder/settings-panel/BlockLayoutTab.tsx +11 -9
- package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
- package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +242 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
- package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
- package/components/builder/settings-panel/CoverSectionSettings.tsx +337 -335
- package/components/builder/settings-panel/PageSettings.tsx +3 -3
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
- package/components/builder/settings-panel/SectionV2Settings.tsx +25 -20
- package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
- package/components/builder/settings-panel/index.ts +1 -0
- package/lib/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-presets.ts +210 -210
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/builder/block-registrations.ts +468 -417
- package/lib/builder/constants.ts +111 -111
- package/lib/builder/serializer/normalizers.ts +14 -0
- package/lib/builder/serializer/serializers.ts +27 -0
- package/lib/builder/store-sections.ts +23 -2
- package/lib/builder/types-slices.ts +428 -414
- package/lib/builder/types.ts +4 -1
- package/lib/config/index.ts +27 -27
- package/lib/sanity/queries.ts +48 -0
- package/lib/sanity/types.ts +112 -1
- package/lib/version.ts +1 -1
- package/package.json +7 -5
- package/sanity/schemas/blocks/audioBlock.ts +69 -69
- package/sanity/schemas/blocks/index.ts +12 -11
- package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
- package/sanity/schemas/index.ts +120 -117
- package/sanity/schemas/objects/coverSection.ts +32 -0
- package/sanity/schemas/objects/parallaxSlide.ts +32 -0
- package/sanity/schemas/pageSectionV2.ts +32 -0
- package/styles/admin.css +85 -85
- package/styles/animations.css +237 -237
- 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:
|
|
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
|
|
540
|
-
|
|
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
|
|
548
|
-
|
|
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
|
-
{/*
|
|
562
|
+
{/* Keyboard shortcuts — tabler option (Mac ⌥ glyph) */}
|
|
554
563
|
<button
|
|
555
564
|
onClick={() => setShowHelp(true)}
|
|
556
|
-
className="rounded-lg
|
|
557
|
-
|
|
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
|
|
566
|
-
|
|
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
|
-
<
|
|
570
|
-
<path d="
|
|
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
|
-
|
|
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-[#
|
|
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-
|
|
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: "
|
|
838
|
+
border: "1.5px dashed #7500d5",
|
|
822
839
|
}}
|
|
823
840
|
>
|
|
824
841
|
+ Add Section
|
package/app/admin/pages/page.tsx
CHANGED
|
@@ -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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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-[#
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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-[#
|
|
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-[#
|
|
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"
|
|
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"
|
|
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"
|
|
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>
|
package/app/admin/setup/page.tsx
CHANGED
|
@@ -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-[#
|
|
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-[#
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|