@morphika/andami 0.1.2
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/LICENSE +21 -0
- package/README.md +50 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +212 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import type { PageListItem, PageType } from "../../../lib/sanity/types";
|
|
7
|
+
import { adminAssetUrl } from "../../../lib/assets";
|
|
8
|
+
import { csrfHeaders } from "../../../lib/csrf-client";
|
|
9
|
+
import AssetBrowser from "../../../components/builder/AssetBrowser";
|
|
10
|
+
import PublishToggle from "../../../components/admin/PublishToggle";
|
|
11
|
+
import { EditIcon, DuplicateIcon, DeleteIcon, PreviewIcon } from "../../../components/admin/icons";
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Helpers
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
function slugify(text: string): string {
|
|
18
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// Media Picker — unified Image + Video with tab toggle
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
type MediaTab = "image" | "video";
|
|
26
|
+
|
|
27
|
+
function MediaPicker({
|
|
28
|
+
imagePath,
|
|
29
|
+
videoPath,
|
|
30
|
+
onImageChange,
|
|
31
|
+
onVideoChange,
|
|
32
|
+
onBrowserOpenChange,
|
|
33
|
+
}: {
|
|
34
|
+
imagePath: string;
|
|
35
|
+
videoPath: string;
|
|
36
|
+
onImageChange: (path: string) => void;
|
|
37
|
+
onVideoChange: (path: string) => void;
|
|
38
|
+
onBrowserOpenChange?: (open: boolean) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [activeTab, setActiveTab] = useState<MediaTab>("image");
|
|
41
|
+
const [browserOpen, setBrowserOpen] = useState(false);
|
|
42
|
+
|
|
43
|
+
const openBrowser = () => { setBrowserOpen(true); onBrowserOpenChange?.(true); };
|
|
44
|
+
const closeBrowser = () => { setBrowserOpen(false); onBrowserOpenChange?.(false); };
|
|
45
|
+
|
|
46
|
+
const currentValue = activeTab === "image" ? imagePath : videoPath;
|
|
47
|
+
const currentOnChange = activeTab === "image" ? onImageChange : onVideoChange;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div>
|
|
51
|
+
{/* Tab header */}
|
|
52
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
53
|
+
<div
|
|
54
|
+
className="inline-flex items-center rounded-lg bg-neutral-100"
|
|
55
|
+
style={{ padding: 2 }}
|
|
56
|
+
>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => setActiveTab("image")}
|
|
60
|
+
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-all duration-150"
|
|
61
|
+
style={{
|
|
62
|
+
backgroundColor: activeTab === "image" ? "#fff" : "transparent",
|
|
63
|
+
color: activeTab === "image" ? "#171717" : "#a3a3a3",
|
|
64
|
+
boxShadow: activeTab === "image" ? "0 1px 2px rgba(0,0,0,0.06)" : "none",
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
68
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
69
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
70
|
+
<polyline points="21 15 16 10 5 21" />
|
|
71
|
+
</svg>
|
|
72
|
+
Image
|
|
73
|
+
{imagePath && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
|
|
74
|
+
</button>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => setActiveTab("video")}
|
|
78
|
+
className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[11px] font-medium transition-all duration-150"
|
|
79
|
+
style={{
|
|
80
|
+
backgroundColor: activeTab === "video" ? "#fff" : "transparent",
|
|
81
|
+
color: activeTab === "video" ? "#171717" : "#a3a3a3",
|
|
82
|
+
boxShadow: activeTab === "video" ? "0 1px 2px rgba(0,0,0,0.06)" : "none",
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
86
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
87
|
+
</svg>
|
|
88
|
+
Video
|
|
89
|
+
{videoPath && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
<span className="text-[10px] text-neutral-400">
|
|
93
|
+
{activeTab === "image" ? "Preview thumbnail" : "Hover playback"}
|
|
94
|
+
</span>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Media preview area */}
|
|
98
|
+
{currentValue ? (
|
|
99
|
+
<div className="relative aspect-[4/3] rounded-lg overflow-hidden bg-neutral-100 border border-neutral-200">
|
|
100
|
+
{activeTab === "image" ? (
|
|
101
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
102
|
+
<img
|
|
103
|
+
src={adminAssetUrl(currentValue)}
|
|
104
|
+
alt="Thumbnail"
|
|
105
|
+
className="w-full h-full object-cover"
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<video
|
|
109
|
+
src={adminAssetUrl(currentValue)}
|
|
110
|
+
className="w-full h-full object-cover"
|
|
111
|
+
muted
|
|
112
|
+
loop
|
|
113
|
+
playsInline
|
|
114
|
+
autoPlay
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
<div className="absolute inset-0 bg-black/0 hover:bg-black/30 transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => openBrowser()}
|
|
121
|
+
className="rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-neutral-700 shadow-sm hover:bg-white transition-colors"
|
|
122
|
+
>
|
|
123
|
+
Change
|
|
124
|
+
</button>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => currentOnChange("")}
|
|
128
|
+
className="rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-red-500 shadow-sm hover:bg-white transition-colors ml-2"
|
|
129
|
+
>
|
|
130
|
+
Remove
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
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
|
+
>
|
|
140
|
+
{activeTab === "image" ? (
|
|
141
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400">
|
|
142
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
143
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
144
|
+
<polyline points="21 15 16 10 5 21" />
|
|
145
|
+
</svg>
|
|
146
|
+
) : (
|
|
147
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-400">
|
|
148
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
149
|
+
</svg>
|
|
150
|
+
)}
|
|
151
|
+
<span className="text-xs text-neutral-400">
|
|
152
|
+
{activeTab === "image" ? "Choose preview image" : "Choose cover video"}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{/* Asset browser (shared, switches filterType) */}
|
|
158
|
+
<AssetBrowser
|
|
159
|
+
open={browserOpen}
|
|
160
|
+
onSelect={(path) => {
|
|
161
|
+
currentOnChange(path);
|
|
162
|
+
closeBrowser();
|
|
163
|
+
}}
|
|
164
|
+
onClose={() => closeBrowser()}
|
|
165
|
+
filterType={activeTab}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================
|
|
172
|
+
// Create Project Modal
|
|
173
|
+
// ============================================
|
|
174
|
+
|
|
175
|
+
function CreateProjectModal({
|
|
176
|
+
open,
|
|
177
|
+
onClose,
|
|
178
|
+
onCreated,
|
|
179
|
+
}: {
|
|
180
|
+
open: boolean;
|
|
181
|
+
onClose: () => void;
|
|
182
|
+
onCreated: () => void;
|
|
183
|
+
}) {
|
|
184
|
+
const [title, setTitle] = useState("");
|
|
185
|
+
const [slug, setSlug] = useState("");
|
|
186
|
+
const [thumbnailPath, setThumbnailPath] = useState("");
|
|
187
|
+
const [coverVideo, setCoverVideo] = useState("");
|
|
188
|
+
const [error, setError] = useState("");
|
|
189
|
+
const [creating, setCreating] = useState(false);
|
|
190
|
+
const [autoSlug, setAutoSlug] = useState(true);
|
|
191
|
+
const [assetBrowserOpen, setAssetBrowserOpen] = useState(false);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!open) return;
|
|
195
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
196
|
+
// Don't close modal if the asset browser is open (it handles its own Escape)
|
|
197
|
+
if (e.key === "Escape" && !assetBrowserOpen) onClose();
|
|
198
|
+
};
|
|
199
|
+
window.addEventListener("keydown", handleKey);
|
|
200
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
201
|
+
}, [open, onClose, assetBrowserOpen]);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (autoSlug && title) {
|
|
205
|
+
setSlug(slugify(title));
|
|
206
|
+
}
|
|
207
|
+
}, [title, autoSlug]);
|
|
208
|
+
|
|
209
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
if (!title || !slug) { setError("Title and slug are required"); return; }
|
|
212
|
+
setCreating(true);
|
|
213
|
+
setError("");
|
|
214
|
+
try {
|
|
215
|
+
const res = await fetch("/api/admin/pages", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "Content-Type": "application/json", ...csrfHeaders() },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
title,
|
|
220
|
+
slug,
|
|
221
|
+
page_type: "project" as PageType,
|
|
222
|
+
...(thumbnailPath ? { thumbnail_path: thumbnailPath } : {}),
|
|
223
|
+
...(coverVideo ? { cover_video: coverVideo } : {}),
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
const data = await res.json();
|
|
227
|
+
if (!res.ok) { setError(data.error || "Failed to create"); return; }
|
|
228
|
+
setTitle(""); setSlug(""); setAutoSlug(true); setThumbnailPath(""); setCoverVideo("");
|
|
229
|
+
onCreated(); onClose();
|
|
230
|
+
} catch { setError("Network error"); }
|
|
231
|
+
finally { setCreating(false); }
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (!open) return null;
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
|
238
|
+
<div className="w-full max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
239
|
+
<h2 className="text-sm font-medium text-neutral-900 mb-4">Add New Project</h2>
|
|
240
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
241
|
+
<div>
|
|
242
|
+
<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
|
+
</div>
|
|
245
|
+
<div>
|
|
246
|
+
<label className="block text-xs text-neutral-500 mb-1">URL Slug</label>
|
|
247
|
+
<div className="flex items-center gap-1">
|
|
248
|
+
<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
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
<MediaPicker
|
|
253
|
+
imagePath={thumbnailPath}
|
|
254
|
+
videoPath={coverVideo}
|
|
255
|
+
onImageChange={setThumbnailPath}
|
|
256
|
+
onVideoChange={setCoverVideo}
|
|
257
|
+
onBrowserOpenChange={setAssetBrowserOpen}
|
|
258
|
+
/>
|
|
259
|
+
{error && <p className="text-xs text-[var(--admin-error)]">{error}</p>}
|
|
260
|
+
<div className="flex gap-3 justify-end pt-2">
|
|
261
|
+
<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
|
+
</div>
|
|
264
|
+
</form>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============================================
|
|
271
|
+
// Edit Project Modal
|
|
272
|
+
// ============================================
|
|
273
|
+
|
|
274
|
+
function EditProjectModal({
|
|
275
|
+
project,
|
|
276
|
+
onClose,
|
|
277
|
+
onSaved,
|
|
278
|
+
}: {
|
|
279
|
+
project: PageListItem | null;
|
|
280
|
+
onClose: () => void;
|
|
281
|
+
onSaved: () => void;
|
|
282
|
+
}) {
|
|
283
|
+
const [title, setTitle] = useState("");
|
|
284
|
+
const [slug, setSlug] = useState("");
|
|
285
|
+
const [thumbnailPath, setThumbnailPath] = useState("");
|
|
286
|
+
const [coverVideo, setCoverVideo] = useState("");
|
|
287
|
+
const [error, setError] = useState("");
|
|
288
|
+
const [saving, setSaving] = useState(false);
|
|
289
|
+
const [assetBrowserOpen, setAssetBrowserOpen] = useState(false);
|
|
290
|
+
|
|
291
|
+
// Populate form when project changes
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (project) {
|
|
294
|
+
setTitle(project.title);
|
|
295
|
+
setSlug(project.slug.current);
|
|
296
|
+
setThumbnailPath(project.thumbnail_path || "");
|
|
297
|
+
setCoverVideo(project.cover_video || "");
|
|
298
|
+
setError("");
|
|
299
|
+
}
|
|
300
|
+
}, [project]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (!project) return;
|
|
304
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
305
|
+
if (e.key === "Escape" && !assetBrowserOpen) onClose();
|
|
306
|
+
};
|
|
307
|
+
window.addEventListener("keydown", handleKey);
|
|
308
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
309
|
+
}, [project, onClose, assetBrowserOpen]);
|
|
310
|
+
|
|
311
|
+
if (!project) return null;
|
|
312
|
+
|
|
313
|
+
const handleSave = async (e: React.FormEvent) => {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
if (!title || !slug) { setError("Title and slug are required"); return; }
|
|
316
|
+
setSaving(true);
|
|
317
|
+
setError("");
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(`/api/admin/pages/${project.slug.current}`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "Content-Type": "application/json", ...csrfHeaders() },
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
title,
|
|
324
|
+
slug: { _type: "slug", current: slug },
|
|
325
|
+
thumbnail_path: thumbnailPath || null,
|
|
326
|
+
cover_video: coverVideo || null,
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
const data = await res.json().catch(() => ({ error: "Save failed" }));
|
|
331
|
+
setError(data.error || "Save failed");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
onSaved();
|
|
335
|
+
onClose();
|
|
336
|
+
} catch { setError("Network error"); }
|
|
337
|
+
finally { setSaving(false); }
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
|
342
|
+
<div className="w-full max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
343
|
+
<h2 className="text-sm font-medium text-neutral-900 mb-4">Edit Project</h2>
|
|
344
|
+
<form onSubmit={handleSave} className="space-y-4">
|
|
345
|
+
<div>
|
|
346
|
+
<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
|
+
</div>
|
|
349
|
+
<div>
|
|
350
|
+
<label className="block text-xs text-neutral-500 mb-1">URL Slug</label>
|
|
351
|
+
<div className="flex items-center gap-1">
|
|
352
|
+
<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
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
<MediaPicker
|
|
357
|
+
imagePath={thumbnailPath}
|
|
358
|
+
videoPath={coverVideo}
|
|
359
|
+
onImageChange={setThumbnailPath}
|
|
360
|
+
onVideoChange={setCoverVideo}
|
|
361
|
+
onBrowserOpenChange={setAssetBrowserOpen}
|
|
362
|
+
/>
|
|
363
|
+
{error && <p className="text-xs text-[var(--admin-error)]">{error}</p>}
|
|
364
|
+
<div className="flex gap-3 justify-end pt-2">
|
|
365
|
+
<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
|
+
</div>
|
|
368
|
+
</form>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ============================================
|
|
375
|
+
// Delete Confirm Modal
|
|
376
|
+
// ============================================
|
|
377
|
+
|
|
378
|
+
function DeleteConfirmModal({
|
|
379
|
+
page,
|
|
380
|
+
onClose,
|
|
381
|
+
onDeleted,
|
|
382
|
+
}: {
|
|
383
|
+
page: PageListItem | null;
|
|
384
|
+
onClose: () => void;
|
|
385
|
+
onDeleted: () => void;
|
|
386
|
+
}) {
|
|
387
|
+
const [deleting, setDeleting] = useState(false);
|
|
388
|
+
const [error, setError] = useState("");
|
|
389
|
+
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (!page) return;
|
|
392
|
+
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
393
|
+
window.addEventListener("keydown", handleKey);
|
|
394
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
395
|
+
}, [page, onClose]);
|
|
396
|
+
|
|
397
|
+
useEffect(() => { setError(""); }, [page]);
|
|
398
|
+
|
|
399
|
+
if (!page) return null;
|
|
400
|
+
|
|
401
|
+
const handleDelete = async () => {
|
|
402
|
+
setDeleting(true); setError("");
|
|
403
|
+
try {
|
|
404
|
+
const res = await fetch(`/api/admin/pages/${page.slug.current}`, { method: "DELETE", headers: { ...csrfHeaders() } });
|
|
405
|
+
if (res.ok) { onDeleted(); onClose(); }
|
|
406
|
+
else {
|
|
407
|
+
const data = await res.json().catch(() => ({ error: "Delete failed" }));
|
|
408
|
+
setError(data.error || "Delete failed");
|
|
409
|
+
}
|
|
410
|
+
} catch { setError("Network error"); }
|
|
411
|
+
finally { setDeleting(false); }
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
|
416
|
+
<div className="w-full max-w-sm rounded-2xl border border-neutral-200 bg-white p-6 shadow-2xl">
|
|
417
|
+
<h2 className="text-sm font-medium text-neutral-900 mb-3">Delete Project</h2>
|
|
418
|
+
<p className="text-xs text-neutral-500 mb-6">
|
|
419
|
+
Are you sure you want to delete “{page.title}”? This action cannot be undone.
|
|
420
|
+
</p>
|
|
421
|
+
{error && <p className="text-xs text-[var(--admin-error)] mb-4">{error}</p>}
|
|
422
|
+
<div className="flex gap-3 justify-end">
|
|
423
|
+
<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>
|
|
424
|
+
<button onClick={handleDelete} disabled={deleting} className="rounded-lg bg-[var(--admin-error)] px-5 py-2.5 text-sm font-medium text-white hover:bg-[var(--admin-error-dark)] transition-colors disabled:opacity-50">{deleting ? "Deleting..." : "Delete"}</button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ============================================
|
|
432
|
+
// Projects Page
|
|
433
|
+
// ============================================
|
|
434
|
+
|
|
435
|
+
type ViewMode = "list" | "thumb";
|
|
436
|
+
|
|
437
|
+
export default function AdminProjectsPage() {
|
|
438
|
+
const router = useRouter();
|
|
439
|
+
const [projects, setProjects] = useState<PageListItem[]>([]);
|
|
440
|
+
const [loading, setLoading] = useState(true);
|
|
441
|
+
const [search, setSearch] = useState("");
|
|
442
|
+
const [viewMode, setViewMode] = useState<ViewMode>("thumb");
|
|
443
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
444
|
+
const [editingProject, setEditingProject] = useState<PageListItem | null>(null);
|
|
445
|
+
const [deletingProject, setDeletingProject] = useState<PageListItem | null>(null);
|
|
446
|
+
const [duplicating, setDuplicating] = useState<string | null>(null);
|
|
447
|
+
const fetchProjects = useCallback(async () => {
|
|
448
|
+
try {
|
|
449
|
+
const res = await fetch("/api/admin/pages");
|
|
450
|
+
const data = await res.json();
|
|
451
|
+
const allPages: PageListItem[] = data.pages || [];
|
|
452
|
+
// Filter to projects only — thumbnail_path comes from the query now
|
|
453
|
+
setProjects(allPages.filter((p) => p.page_type === "project"));
|
|
454
|
+
} catch {
|
|
455
|
+
setProjects([]);
|
|
456
|
+
} finally {
|
|
457
|
+
setLoading(false);
|
|
458
|
+
}
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
461
|
+
useEffect(() => { fetchProjects(); }, [fetchProjects]);
|
|
462
|
+
|
|
463
|
+
const filtered = projects.filter((p) => {
|
|
464
|
+
if (!search) return true;
|
|
465
|
+
const q = search.toLowerCase();
|
|
466
|
+
return p.title.toLowerCase().includes(q) || p.slug.current.toLowerCase().includes(q);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const handleDuplicate = async (project: PageListItem) => {
|
|
470
|
+
setDuplicating(project._id);
|
|
471
|
+
try {
|
|
472
|
+
const res = await fetch(`/api/admin/pages/${project.slug.current}/duplicate`, { method: "POST", headers: { ...csrfHeaders() } });
|
|
473
|
+
if (res.ok) {
|
|
474
|
+
await fetchProjects();
|
|
475
|
+
} else {
|
|
476
|
+
const data = await res.json().catch(() => ({ error: "Duplicate failed" }));
|
|
477
|
+
console.error("Duplicate failed:", data.error || `HTTP ${res.status}`);
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
console.error("Duplicate operation failed:", err);
|
|
481
|
+
} finally {
|
|
482
|
+
setDuplicating(null);
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<div>
|
|
488
|
+
{/* Header */}
|
|
489
|
+
<div className="flex items-center justify-between mb-6">
|
|
490
|
+
<h1 className="text-2xl font-semibold text-neutral-900">Projects</h1>
|
|
491
|
+
<div className="flex items-center gap-3">
|
|
492
|
+
{/* View toggle */}
|
|
493
|
+
<div className="relative">
|
|
494
|
+
<select
|
|
495
|
+
value={viewMode === "thumb" ? "Thumb View" : "List View"}
|
|
496
|
+
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
|
+
>
|
|
499
|
+
<option>List View</option>
|
|
500
|
+
<option>Thumb View</option>
|
|
501
|
+
</select>
|
|
502
|
+
<svg className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
503
|
+
<polyline points="6 9 12 15 18 9" />
|
|
504
|
+
</svg>
|
|
505
|
+
</div>
|
|
506
|
+
<button
|
|
507
|
+
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
|
+
>
|
|
510
|
+
+ New Project
|
|
511
|
+
</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
{/* Search */}
|
|
516
|
+
<div className="mb-6">
|
|
517
|
+
<input
|
|
518
|
+
type="text"
|
|
519
|
+
value={search}
|
|
520
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
521
|
+
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
|
+
/>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
{/* Content */}
|
|
527
|
+
{loading ? (
|
|
528
|
+
<div className="flex items-center justify-center py-20">
|
|
529
|
+
<p className="text-xs text-neutral-400 animate-pulse">Loading projects...</p>
|
|
530
|
+
</div>
|
|
531
|
+
) : filtered.length === 0 ? (
|
|
532
|
+
<div className="flex flex-col items-center justify-center py-20 border border-dashed border-neutral-300 rounded-lg">
|
|
533
|
+
<p className="text-sm text-neutral-500 mb-2">
|
|
534
|
+
{projects.length === 0 ? "No projects yet" : "No projects match your search"}
|
|
535
|
+
</p>
|
|
536
|
+
{projects.length === 0 && (
|
|
537
|
+
<button onClick={() => setShowCreate(true)} className="text-xs text-[#076bff] hover:underline">
|
|
538
|
+
Create your first project
|
|
539
|
+
</button>
|
|
540
|
+
)}
|
|
541
|
+
</div>
|
|
542
|
+
) : viewMode === "list" ? (
|
|
543
|
+
/* ====== LIST VIEW ====== */
|
|
544
|
+
<div>
|
|
545
|
+
{/* Table header */}
|
|
546
|
+
<div className="grid grid-cols-[1fr_120px_140px_180px] gap-4 px-5 py-3 border-b border-neutral-200">
|
|
547
|
+
<span className="text-xs font-medium uppercase tracking-wider text-neutral-400">Title</span>
|
|
548
|
+
<span className="text-xs font-medium uppercase tracking-wider text-neutral-400">Status</span>
|
|
549
|
+
<span className="text-xs font-medium uppercase tracking-wider text-neutral-400">Created</span>
|
|
550
|
+
<span className="text-xs font-medium uppercase tracking-wider text-neutral-400 text-right">Actions</span>
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
<div className="space-y-1 mt-1">
|
|
554
|
+
{filtered.map((project) => (
|
|
555
|
+
<div
|
|
556
|
+
key={project._id}
|
|
557
|
+
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
|
+
onClick={() => router.push(`/admin/projects/${project.slug.current}`)}
|
|
559
|
+
>
|
|
560
|
+
<p className="text-sm text-neutral-900 group-hover:text-[#076bff] transition-colors">
|
|
561
|
+
{project.title}
|
|
562
|
+
</p>
|
|
563
|
+
|
|
564
|
+
<div onClick={(e) => e.stopPropagation()}>
|
|
565
|
+
<PublishToggle
|
|
566
|
+
mode="api"
|
|
567
|
+
isDraft={!!project.draft_mode}
|
|
568
|
+
slug={project.slug.current}
|
|
569
|
+
onToggled={fetchProjects}
|
|
570
|
+
/>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
<span className="text-xs text-neutral-500">
|
|
574
|
+
{project.published_at
|
|
575
|
+
? new Date(project.published_at).toLocaleDateString("en-US", { day: "2-digit", month: "2-digit", year: "numeric" })
|
|
576
|
+
: "\u2014"}
|
|
577
|
+
</span>
|
|
578
|
+
|
|
579
|
+
<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
|
+
<EditIcon />
|
|
582
|
+
</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">
|
|
584
|
+
<DuplicateIcon />
|
|
585
|
+
</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">
|
|
587
|
+
<DeleteIcon />
|
|
588
|
+
</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">
|
|
590
|
+
<PreviewIcon />
|
|
591
|
+
</a>
|
|
592
|
+
</div>
|
|
593
|
+
</div>
|
|
594
|
+
))}
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
) : (
|
|
598
|
+
/* ====== THUMB VIEW (Semplice style) ====== */
|
|
599
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
|
|
600
|
+
{filtered.map((project) => (
|
|
601
|
+
<div key={project._id} className="group relative">
|
|
602
|
+
{/* Thumbnail card */}
|
|
603
|
+
<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"
|
|
605
|
+
onClick={() => router.push(`/admin/projects/${project.slug.current}`)}
|
|
606
|
+
>
|
|
607
|
+
{project.thumbnail_path ? (
|
|
608
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
609
|
+
<img
|
|
610
|
+
src={adminAssetUrl(project.thumbnail_path)}
|
|
611
|
+
alt={project.title}
|
|
612
|
+
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
613
|
+
loading="lazy"
|
|
614
|
+
onError={(e) => {
|
|
615
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
616
|
+
}}
|
|
617
|
+
/>
|
|
618
|
+
) : (
|
|
619
|
+
<div className="w-full h-full flex items-center justify-center bg-neutral-100">
|
|
620
|
+
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-neutral-300">
|
|
621
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
622
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
623
|
+
<polyline points="21 15 16 10 5 21" />
|
|
624
|
+
</svg>
|
|
625
|
+
</div>
|
|
626
|
+
)}
|
|
627
|
+
|
|
628
|
+
{/* Hover overlay with actions */}
|
|
629
|
+
<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
|
+
<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">
|
|
632
|
+
<EditIcon />
|
|
633
|
+
</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">
|
|
635
|
+
<DuplicateIcon />
|
|
636
|
+
</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">
|
|
638
|
+
<DeleteIcon />
|
|
639
|
+
</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">
|
|
641
|
+
<PreviewIcon />
|
|
642
|
+
</a>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{/* Info below thumbnail */}
|
|
648
|
+
<div className="mt-2 px-1">
|
|
649
|
+
<PublishToggle
|
|
650
|
+
mode="api"
|
|
651
|
+
isDraft={!!project.draft_mode}
|
|
652
|
+
slug={project.slug.current}
|
|
653
|
+
onToggled={fetchProjects}
|
|
654
|
+
/>
|
|
655
|
+
<p className="text-sm text-neutral-900 mt-0.5 truncate">
|
|
656
|
+
{project.title}
|
|
657
|
+
</p>
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
<CreateProjectModal open={showCreate} onClose={() => setShowCreate(false)} onCreated={fetchProjects} />
|
|
665
|
+
<EditProjectModal project={editingProject} onClose={() => setEditingProject(null)} onSaved={fetchProjects} />
|
|
666
|
+
<DeleteConfirmModal page={deletingProject} onClose={() => setDeletingProject(null)} onDeleted={fetchProjects} />
|
|
667
|
+
</div>
|
|
668
|
+
);
|
|
669
|
+
}
|