@morphika/andami 0.5.1 → 0.5.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.
Files changed (117) hide show
  1. package/app/admin/assets/page.tsx +6 -6
  2. package/app/admin/database/page.tsx +302 -302
  3. package/app/admin/error.tsx +53 -53
  4. package/app/admin/layout.tsx +320 -320
  5. package/app/admin/navigation/page.tsx +255 -255
  6. package/app/admin/pages/[slug]/page.tsx +6 -6
  7. package/app/admin/pages/page.tsx +11 -11
  8. package/app/admin/projects/page.tsx +14 -14
  9. package/app/admin/setup/page.tsx +1 -1
  10. package/app/admin/styles/page.tsx +1 -1
  11. package/components/admin/MetadataEditor.tsx +6 -6
  12. package/components/admin/nav-builder/NavBuilder.tsx +1 -1
  13. package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
  14. package/components/admin/nav-builder/NavGridCell.tsx +48 -48
  15. package/components/admin/nav-builder/NavGridItem.tsx +4 -4
  16. package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
  17. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
  18. package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
  19. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
  20. package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
  21. package/components/admin/nav-builder/NavSettingsFields.tsx +514 -514
  22. package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
  23. package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
  24. package/components/admin/setup-wizard/DoneStep.tsx +1 -1
  25. package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
  26. package/components/admin/setup-wizard/StorageStep.tsx +2 -2
  27. package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
  28. package/components/admin/styles/ColorsEditor.tsx +2 -2
  29. package/components/admin/styles/FontsEditor.tsx +6 -6
  30. package/components/admin/styles/GridLayoutEditor.tsx +9 -9
  31. package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
  32. package/components/admin/styles/TypographyEditor.tsx +6 -6
  33. package/components/admin/styles/shared.tsx +68 -68
  34. package/components/blocks/AudioBlockRenderer.tsx +286 -286
  35. package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
  36. package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
  37. package/components/builder/BlockCardIcons.tsx +316 -316
  38. package/components/builder/BlockTypePicker.tsx +1 -1
  39. package/components/builder/BubbleIcons.tsx +90 -0
  40. package/components/builder/BuilderCanvas.tsx +2 -0
  41. package/components/builder/CanvasMinimap.tsx +2 -2
  42. package/components/builder/CoverSectionCanvas.tsx +363 -363
  43. package/components/builder/DeviceFrame.tsx +1 -1
  44. package/components/builder/DndWrapper.tsx +3 -3
  45. package/components/builder/InsertionLines.tsx +1 -1
  46. package/components/builder/SectionCardIcons.tsx +421 -320
  47. package/components/builder/SectionEditorBar.tsx +1 -1
  48. package/components/builder/SectionTypePicker.tsx +4 -4
  49. package/components/builder/SectionV2Canvas.tsx +1 -1
  50. package/components/builder/SectionV2Column.tsx +69 -67
  51. package/components/builder/SortableBlock.tsx +93 -73
  52. package/components/builder/SortableRow.tsx +27 -26
  53. package/components/builder/VirtualAssetGrid.tsx +2 -2
  54. package/components/builder/asset-browser/R2BrowserContent.tsx +11 -11
  55. package/components/builder/blockStyles.tsx +192 -185
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -141
  57. package/components/builder/color-picker/ColorInputs.tsx +105 -105
  58. package/components/builder/color-picker/EyedropperButton.tsx +74 -74
  59. package/components/builder/color-picker/HueSlider.tsx +124 -124
  60. package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
  61. package/components/builder/color-picker/SwatchBar.tsx +93 -93
  62. package/components/builder/editors/AudioBlockEditor.tsx +242 -242
  63. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
  64. package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
  65. package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
  66. package/components/builder/editors/HoverEffectPicker.tsx +2 -2
  67. package/components/builder/editors/ImageBlockEditor.tsx +2 -2
  68. package/components/builder/editors/ImageGridBlockEditor.tsx +4 -4
  69. package/components/builder/editors/MarqueeBlockEditor.tsx +621 -0
  70. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
  71. package/components/builder/editors/ProjectGridEditor.tsx +9 -9
  72. package/components/builder/editors/SpacerBlockEditor.tsx +5 -5
  73. package/components/builder/editors/StaggerSettings.tsx +109 -109
  74. package/components/builder/editors/TextBlockEditor.tsx +3 -3
  75. package/components/builder/editors/TextStylePicker.tsx +1 -1
  76. package/components/builder/editors/VideoBlockEditor.tsx +2 -2
  77. package/components/builder/editors/index.ts +11 -10
  78. package/components/builder/editors/shared.tsx +6 -6
  79. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
  80. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
  81. package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
  82. package/components/builder/live-preview/LiveImagePreview.tsx +1 -1
  83. package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
  84. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
  85. package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
  86. package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -291
  87. package/components/builder/settings-panel/AnimationTab.tsx +138 -138
  88. package/components/builder/settings-panel/BlockLayoutTab.tsx +7 -7
  89. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
  90. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  91. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
  92. package/components/builder/settings-panel/CoverSectionSettings.tsx +335 -335
  93. package/components/builder/settings-panel/PageSettings.tsx +3 -3
  94. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  95. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
  96. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
  97. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  98. package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
  99. package/lib/animation/enter-types.ts +1 -0
  100. package/lib/animation/hover-effect-presets.ts +210 -210
  101. package/lib/animation/hover-effect-types.ts +1 -0
  102. package/lib/builder/block-registrations.ts +468 -417
  103. package/lib/builder/constants.ts +111 -111
  104. package/lib/builder/store-sections.ts +2 -2
  105. package/lib/builder/types-slices.ts +414 -414
  106. package/lib/builder/types.ts +4 -1
  107. package/lib/config/index.ts +27 -27
  108. package/lib/sanity/types.ts +98 -1
  109. package/lib/version.ts +1 -1
  110. package/package.json +1 -1
  111. package/sanity/schemas/blocks/audioBlock.ts +69 -69
  112. package/sanity/schemas/blocks/index.ts +12 -11
  113. package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
  114. package/sanity/schemas/index.ts +120 -117
  115. package/styles/admin.css +85 -85
  116. package/styles/animations.css +237 -237
  117. package/styles/base.css +114 -114
@@ -65,6 +65,7 @@ export const ALL_BLOCK_INFO: BlockTypeInfo[] = [
65
65
  // Section blocks — not in the content picker but still need label/icon lookup
66
66
  { type: "projectGridBlock", label: "Project Grid", description: "Staggered project showcase grid", group: "generic", icon: "⬡", category: "section" },
67
67
  { type: "projectCarouselBlock", label: "Project Carousel", description: "Horizontal carousel of projects — great for end-of-page 'keep browsing'", group: "generic", icon: "▸", category: "section" },
68
+ { type: "marqueeBlock", label: "Marquee", description: "Horizontal scrolling ticker of text and images", group: "generic", icon: "⇄", category: "section" },
68
69
  ];
69
70
 
70
71
  // Parallax group info — used by BuilderCanvas/SortableRow for label/icon lookup (not a block)
@@ -75,12 +76,13 @@ export const PARALLAX_GROUP_INFO = { label: "Parallax Showcase", icon: "▽" };
75
76
  // ============================================
76
77
 
77
78
  /** Section block types that create a full-width row with a pre-populated block */
78
- export type SectionBlockType = "projectGridBlock" | "projectCarouselBlock";
79
+ export type SectionBlockType = "projectGridBlock" | "projectCarouselBlock" | "marqueeBlock";
79
80
 
80
81
  /** Set for fast lookup — used by SortableBlock, ColumnDropZone, SortableRow to suppress inner chrome */
81
82
  const SECTION_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
82
83
  "projectGridBlock",
83
84
  "projectCarouselBlock",
85
+ "marqueeBlock",
84
86
  ]);
85
87
 
86
88
  /** Check if a block type is a section-level block (should render without block/column chrome) */
@@ -114,6 +116,7 @@ export const SECTION_TYPE_REGISTRY: SectionTypeInfo[] = [
114
116
  { type: "coverSection", label: "Cover Section", description: "Full-viewport section with background and proportional rows", icon: "◆" },
115
117
  { type: "projectGridBlock", label: "Project Grid", description: "Staggered project showcase grid", icon: "⬡", blockType: "projectGridBlock" },
116
118
  { type: "projectCarouselBlock", label: "Project Carousel", description: "Horizontal 'keep browsing' carousel of projects", icon: "▸", blockType: "projectCarouselBlock" },
119
+ { type: "marqueeBlock", label: "Marquee", description: "Horizontal scrolling ticker of text and images", icon: "⇄", blockType: "marqueeBlock" },
117
120
  { type: "parallaxGroup", label: "Parallax Section", description: "Full-screen parallax showcase with V2 slides", icon: "▽" },
118
121
  ];
119
122
 
@@ -1,28 +1,28 @@
1
- /**
2
- * Site configuration accessor.
3
- *
4
- * Uses globalThis + Symbol.for to guarantee a true singleton even when
5
- * the bundler creates multiple module instances of this file.
6
- */
7
-
8
- import type { SiteConfig } from "./types";
9
-
10
- const CONFIG_KEY = Symbol.for("@morphika/andami/siteConfig");
11
- const g = globalThis as unknown as Record<symbol, SiteConfig | undefined>;
12
-
13
- export function registerConfig(config: SiteConfig): void {
14
- g[CONFIG_KEY] = config;
15
- }
16
-
17
- export function getSiteConfig(): SiteConfig {
18
- const cfg = g[CONFIG_KEY];
19
- if (!cfg) {
20
- throw new Error(
21
- "SiteConfig not registered. Call registerConfig(config) in your root layout before using getSiteConfig().\n" +
22
- "See: https://github.com/MorphikaStudio/Morphika_Andami#quick-start",
23
- );
24
- }
25
- return cfg;
26
- }
27
-
1
+ /**
2
+ * Site configuration accessor.
3
+ *
4
+ * Uses globalThis + Symbol.for to guarantee a true singleton even when
5
+ * the bundler creates multiple module instances of this file.
6
+ */
7
+
8
+ import type { SiteConfig } from "./types";
9
+
10
+ const CONFIG_KEY = Symbol.for("@morphika/andami/siteConfig");
11
+ const g = globalThis as unknown as Record<symbol, SiteConfig | undefined>;
12
+
13
+ export function registerConfig(config: SiteConfig): void {
14
+ g[CONFIG_KEY] = config;
15
+ }
16
+
17
+ export function getSiteConfig(): SiteConfig {
18
+ const cfg = g[CONFIG_KEY];
19
+ if (!cfg) {
20
+ throw new Error(
21
+ "SiteConfig not registered. Call registerConfig(config) in your root layout before using getSiteConfig().\n" +
22
+ "See: https://github.com/MorphikaStudio/Morphika_Andami#quick-start",
23
+ );
24
+ }
25
+ return cfg;
26
+ }
27
+
28
28
  export type { SiteConfig } from "./types";
@@ -368,6 +368,102 @@ export interface ProjectGridBlock {
368
368
  responsive?: ResponsiveOverrides<ProjectGridBlock>;
369
369
  }
370
370
 
371
+ // ============================================
372
+ // Marquee Block — horizontal infinite-scroll ticker (Session 184)
373
+ // ============================================
374
+
375
+ /** Plain-text item in a marquee. */
376
+ export interface MarqueeTextItem {
377
+ _key: string;
378
+ _type: "marqueeText";
379
+ text: string;
380
+ }
381
+
382
+ /** Image item in a marquee — asset_path is relative (resolved by the asset proxy). */
383
+ export interface MarqueeImageItem {
384
+ _key: string;
385
+ _type: "marqueeImage";
386
+ asset_path: string;
387
+ alt?: string;
388
+ /** Per-item override, 0–200 px. */
389
+ border_radius?: number;
390
+ /** Fixed width in px. When absent, derived from row_height × natural aspect ratio. */
391
+ width?: number;
392
+ }
393
+
394
+ /** Separator item — inherits block-level typography (size/color/weight/style). */
395
+ export interface MarqueeSeparatorItem {
396
+ _key: string;
397
+ _type: "marqueeSeparator";
398
+ /** 1–4 chars — glyph or emoji. Common: • · — / ▸ ★ */
399
+ character: string;
400
+ }
401
+
402
+ export type MarqueeItem =
403
+ | MarqueeTextItem
404
+ | MarqueeImageItem
405
+ | MarqueeSeparatorItem;
406
+
407
+ /**
408
+ * Font-size scale for text/separator items in a marquee.
409
+ * Mapped to concrete rem/px values by the renderer.
410
+ */
411
+ export type MarqueeFontSize =
412
+ | "s"
413
+ | "base"
414
+ | "l"
415
+ | "xl"
416
+ | "2xl"
417
+ | "3xl"
418
+ | "4xl"
419
+ | "5xl"
420
+ | "6xl";
421
+
422
+ /**
423
+ * Marquee Block — horizontal infinite-scroll ticker.
424
+ *
425
+ * Section-level block: lives inside a full-width column of a `PageSectionV2`.
426
+ * Available on both pages and projects (unlike projectGrid/projectCarousel
427
+ * which are pages-only) — makes sense as an end-of-project "keep browsing"
428
+ * style band.
429
+ *
430
+ * Motion is driven by CSS `@keyframes` with an IntersectionObserver pause
431
+ * when off-screen. Respects `prefers-reduced-motion`. Hover effects are
432
+ * deliberately not supported — a block that is already animating fights
433
+ * scale/tilt hover presets.
434
+ */
435
+ export interface MarqueeBlock {
436
+ _type: "marqueeBlock";
437
+ _key: string;
438
+
439
+ // ─── Content ───
440
+ items: MarqueeItem[];
441
+
442
+ // ─── Motion ───
443
+ direction?: "left" | "right"; // default "left"
444
+ speed?: number; // px/s, 5–600, default 60
445
+ pause_on_hover?: boolean; // default true
446
+
447
+ // ─── Typography (applies to text + separator items) ───
448
+ font_size?: MarqueeFontSize; // default "3xl"
449
+ font_weight?: "400" | "500" | "700" | "900"; // default "700"
450
+ color?: string; // hex or palette token, default "#111111"
451
+ text_style?: "solid" | "outline" | "italic-outline"; // default "solid"
452
+ letter_spacing?: number; // em, default 0
453
+ text_transform?: "none" | "uppercase" | "lowercase"; // default "uppercase"
454
+
455
+ // ─── Layout ───
456
+ gap?: number; // px between items, default 48
457
+ row_height?: number; // px, default 120 (controls image height)
458
+ padding_y?: number; // px, default 16
459
+ background_color?: string; // hex or palette; empty = transparent
460
+
461
+ // ─── Standard block fields ───
462
+ enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
463
+ layout?: BlockLayout;
464
+ responsive?: ResponsiveOverrides<MarqueeBlock>;
465
+ }
466
+
371
467
  // ============================================
372
468
  // Parallax V2 — Group + Slide types (Session 123)
373
469
  // ============================================
@@ -694,7 +790,8 @@ export type ContentBlock =
694
790
  | BeforeAfterBlock
695
791
  | AudioBlock
696
792
  | ProjectGridBlock
697
- | ProjectCarouselBlock;
793
+ | ProjectCarouselBlock
794
+ | MarqueeBlock;
698
795
 
699
796
  // ============================================
700
797
  // Structural types
package/lib/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * Exposed as a plain constant so it can be imported without reading
7
7
  * package.json at runtime.
8
8
  */
9
- export const ANDAMI_VERSION = "0.5.1";
9
+ export const ANDAMI_VERSION = "0.5.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,69 +1,69 @@
1
- import { defineField, defineType } from "sanity";
2
- import { blockLayoutField, blockAnimationFields } from "./blockLayout";
3
-
4
- export const audioBlock = defineType({
5
- name: "audioBlock",
6
- title: "Audio Block",
7
- type: "object",
8
- fields: [
9
- // ── Source ──
10
- defineField({
11
- name: "asset_path",
12
- title: "Audio File",
13
- type: "string",
14
- description: "Relative path to the audio file (mp3, wav, ogg, m4a, aac, flac)",
15
- validation: (Rule) => Rule.required(),
16
- }),
17
- defineField({ name: "alt", title: "Alt Text", type: "string" }),
18
-
19
- // ── Metadata ──
20
- defineField({ name: "title", title: "Title", type: "string" }),
21
- defineField({ name: "artist", title: "Artist", type: "string" }),
22
- defineField({
23
- name: "cover_path",
24
- title: "Cover Art",
25
- type: "string",
26
- description: "Optional relative path to a cover image",
27
- }),
28
-
29
- // ── Appearance ──
30
- defineField({
31
- name: "accent_color",
32
- title: "Accent Color",
33
- type: "string",
34
- description: "Hex color for the play button + progress fill",
35
- initialValue: "#4794E2",
36
- }),
37
- defineField({
38
- name: "width",
39
- title: "Width",
40
- type: "string",
41
- options: {
42
- list: [
43
- { title: "Full", value: "full" },
44
- { title: "Contained", value: "contained" },
45
- { title: "Small", value: "small" },
46
- { title: "Fill", value: "fill" },
47
- ],
48
- },
49
- initialValue: "contained",
50
- }),
51
- defineField({ name: "border_radius", title: "Border Radius", type: "string" }),
52
- defineField({ name: "shadow", title: "Shadow", type: "boolean", initialValue: false }),
53
-
54
- // ── Playback ──
55
- defineField({ name: "autoplay", title: "Autoplay", type: "boolean", initialValue: false }),
56
- defineField({ name: "loop", title: "Loop", type: "boolean", initialValue: false }),
57
- defineField({ name: "muted", title: "Muted", type: "boolean", initialValue: false }),
58
-
59
- ...blockAnimationFields,
60
- blockLayoutField,
61
- ],
62
- preview: {
63
- select: { path: "asset_path", title: "title", artist: "artist" },
64
- prepare({ path, title, artist }) {
65
- const label = title ? (artist ? `${title} — ${artist}` : title) : path || "Audio block";
66
- return { title: label };
67
- },
68
- },
69
- });
1
+ import { defineField, defineType } from "sanity";
2
+ import { blockLayoutField, blockAnimationFields } from "./blockLayout";
3
+
4
+ export const audioBlock = defineType({
5
+ name: "audioBlock",
6
+ title: "Audio Block",
7
+ type: "object",
8
+ fields: [
9
+ // ── Source ──
10
+ defineField({
11
+ name: "asset_path",
12
+ title: "Audio File",
13
+ type: "string",
14
+ description: "Relative path to the audio file (mp3, wav, ogg, m4a, aac, flac)",
15
+ validation: (Rule) => Rule.required(),
16
+ }),
17
+ defineField({ name: "alt", title: "Alt Text", type: "string" }),
18
+
19
+ // ── Metadata ──
20
+ defineField({ name: "title", title: "Title", type: "string" }),
21
+ defineField({ name: "artist", title: "Artist", type: "string" }),
22
+ defineField({
23
+ name: "cover_path",
24
+ title: "Cover Art",
25
+ type: "string",
26
+ description: "Optional relative path to a cover image",
27
+ }),
28
+
29
+ // ── Appearance ──
30
+ defineField({
31
+ name: "accent_color",
32
+ title: "Accent Color",
33
+ type: "string",
34
+ description: "Hex color for the play button + progress fill",
35
+ initialValue: "#3580f9",
36
+ }),
37
+ defineField({
38
+ name: "width",
39
+ title: "Width",
40
+ type: "string",
41
+ options: {
42
+ list: [
43
+ { title: "Full", value: "full" },
44
+ { title: "Contained", value: "contained" },
45
+ { title: "Small", value: "small" },
46
+ { title: "Fill", value: "fill" },
47
+ ],
48
+ },
49
+ initialValue: "contained",
50
+ }),
51
+ defineField({ name: "border_radius", title: "Border Radius", type: "string" }),
52
+ defineField({ name: "shadow", title: "Shadow", type: "boolean", initialValue: false }),
53
+
54
+ // ── Playback ──
55
+ defineField({ name: "autoplay", title: "Autoplay", type: "boolean", initialValue: false }),
56
+ defineField({ name: "loop", title: "Loop", type: "boolean", initialValue: false }),
57
+ defineField({ name: "muted", title: "Muted", type: "boolean", initialValue: false }),
58
+
59
+ ...blockAnimationFields,
60
+ blockLayoutField,
61
+ ],
62
+ preview: {
63
+ select: { path: "asset_path", title: "title", artist: "artist" },
64
+ prepare({ path, title, artist }) {
65
+ const label = title ? (artist ? `${title} — ${artist}` : title) : path || "Audio block";
66
+ return { title: label };
67
+ },
68
+ },
69
+ });
@@ -1,11 +1,12 @@
1
- // Block schemas (10)
2
- export { textBlock } from "./textBlock";
3
- export { imageBlock } from "./imageBlock";
4
- export { imageGridBlock } from "./imageGridBlock";
5
- export { videoBlock } from "./videoBlock";
6
- export { spacerBlock } from "./spacerBlock";
7
- export { buttonBlock } from "./buttonBlock";
8
- export { beforeAfterBlock } from "./beforeAfterBlock";
9
- export { audioBlock } from "./audioBlock";
10
- export { projectGridBlock } from "./projectGridBlock";
11
- export { projectCarouselBlock } from "./projectCarouselBlock";
1
+ // Block schemas (11)
2
+ export { textBlock } from "./textBlock";
3
+ export { imageBlock } from "./imageBlock";
4
+ export { imageGridBlock } from "./imageGridBlock";
5
+ export { videoBlock } from "./videoBlock";
6
+ export { spacerBlock } from "./spacerBlock";
7
+ export { buttonBlock } from "./buttonBlock";
8
+ export { beforeAfterBlock } from "./beforeAfterBlock";
9
+ export { audioBlock } from "./audioBlock";
10
+ export { projectGridBlock } from "./projectGridBlock";
11
+ export { projectCarouselBlock } from "./projectCarouselBlock";
12
+ export { marqueeBlock } from "./marqueeBlock";
@@ -0,0 +1,292 @@
1
+ import { defineField, defineType } from "sanity";
2
+ import { blockLayoutField, blockAnimationFields } from "./blockLayout";
3
+
4
+ /**
5
+ * marqueeBlock — horizontal infinite-scroll ticker.
6
+ *
7
+ * Signature portfolio element. Supports mixed content (text, images,
8
+ * separators) scrolling horizontally at a configurable speed, with
9
+ * pause-on-hover and a hollow-text "outline" typography option for the
10
+ * classic portfolio look.
11
+ *
12
+ * Treated as a **section-level block** (same semantics as
13
+ * projectGridBlock / projectCarouselBlock): lives inside a full-width
14
+ * column of a pageSectionV2 and is added via the "+ Add Section" modal,
15
+ * not the "+ Add Block" picker. Unlike the two project-related section
16
+ * blocks, Marquee is available on **both pages and projects** — the
17
+ * "AVAILABLE FOR WORK" band fits naturally on `/work/[slug]` too.
18
+ *
19
+ * Performance: the renderer uses pure CSS @keyframes + IntersectionObserver
20
+ * to pause the animation when off-screen. Zero JS in the hot path, zero
21
+ * CPU off-screen. Respects `prefers-reduced-motion`.
22
+ *
23
+ * Hover effects deliberately disabled (enterPresets only): a block that
24
+ * is already moving doesn't benefit from scale/tilt hover effects — would
25
+ * fight the motion.
26
+ */
27
+ export const marqueeBlock = defineType({
28
+ name: "marqueeBlock",
29
+ title: "Marquee",
30
+ type: "object",
31
+ fields: [
32
+ // ─── Content (items array) ────────────────────────────────
33
+ defineField({
34
+ name: "items",
35
+ title: "Items",
36
+ type: "array",
37
+ description:
38
+ "Ordered list of text, images and separators. They repeat in order indefinitely as the marquee scrolls.",
39
+ of: [
40
+ {
41
+ type: "object",
42
+ name: "marqueeText",
43
+ title: "Text",
44
+ fields: [
45
+ defineField({
46
+ name: "text",
47
+ title: "Text",
48
+ type: "string",
49
+ validation: (Rule) => Rule.required().min(1),
50
+ }),
51
+ ],
52
+ preview: {
53
+ select: { title: "text" },
54
+ prepare: ({ title }: { title?: string }) => ({
55
+ title: title || "(empty text)",
56
+ subtitle: "Text",
57
+ }),
58
+ },
59
+ },
60
+ {
61
+ type: "object",
62
+ name: "marqueeImage",
63
+ title: "Image",
64
+ fields: [
65
+ defineField({
66
+ name: "asset_path",
67
+ title: "Asset path",
68
+ type: "string",
69
+ description: "Relative path to the image file",
70
+ validation: (Rule) => Rule.required(),
71
+ }),
72
+ defineField({
73
+ name: "alt",
74
+ title: "Alt text",
75
+ type: "string",
76
+ }),
77
+ defineField({
78
+ name: "border_radius",
79
+ title: "Border radius (px)",
80
+ type: "number",
81
+ initialValue: 0,
82
+ validation: (Rule) => Rule.min(0).max(200),
83
+ }),
84
+ defineField({
85
+ name: "width",
86
+ title: "Width (px)",
87
+ type: "number",
88
+ description:
89
+ "Fixed width. Leave empty to derive from the row height and image aspect ratio.",
90
+ validation: (Rule) => Rule.min(16).max(1200),
91
+ }),
92
+ ],
93
+ preview: {
94
+ select: { title: "alt", subtitle: "asset_path" },
95
+ prepare: ({ title, subtitle }: { title?: string; subtitle?: string }) => ({
96
+ title: title || "(no alt)",
97
+ subtitle: `Image · ${subtitle || "no asset"}`,
98
+ }),
99
+ },
100
+ },
101
+ {
102
+ type: "object",
103
+ name: "marqueeSeparator",
104
+ title: "Separator",
105
+ fields: [
106
+ defineField({
107
+ name: "character",
108
+ title: "Character",
109
+ type: "string",
110
+ description:
111
+ "Any character (or emoji) used as a divider. Common: • · — / ▸ ★",
112
+ initialValue: "•",
113
+ validation: (Rule) => Rule.required().min(1).max(4),
114
+ }),
115
+ ],
116
+ preview: {
117
+ select: { title: "character" },
118
+ prepare: ({ title }: { title?: string }) => ({
119
+ title: title || "•",
120
+ subtitle: "Separator",
121
+ }),
122
+ },
123
+ },
124
+ ],
125
+ }),
126
+
127
+ // ─── Motion ───────────────────────────────────────────────
128
+ defineField({
129
+ name: "direction",
130
+ title: "Direction",
131
+ type: "string",
132
+ description: "Horizontal scroll direction",
133
+ options: {
134
+ list: [
135
+ { title: "Left →", value: "left" },
136
+ { title: "← Right", value: "right" },
137
+ ],
138
+ },
139
+ initialValue: "left",
140
+ }),
141
+ defineField({
142
+ name: "speed",
143
+ title: "Speed (px/s)",
144
+ type: "number",
145
+ description:
146
+ "Scroll speed in pixels per second. Decouples from content length (a 20s loop feels radically different with 10 items vs 100).",
147
+ initialValue: 60,
148
+ validation: (Rule) => Rule.min(5).max(600),
149
+ }),
150
+ defineField({
151
+ name: "pause_on_hover",
152
+ title: "Pause on hover",
153
+ type: "boolean",
154
+ initialValue: true,
155
+ }),
156
+
157
+ // ─── Typography (text + separator items) ──────────────────
158
+ defineField({
159
+ name: "font_size",
160
+ title: "Font size",
161
+ type: "string",
162
+ description: "Size scale for text and separator items",
163
+ options: {
164
+ list: [
165
+ { title: "S", value: "s" },
166
+ { title: "Base", value: "base" },
167
+ { title: "L", value: "l" },
168
+ { title: "XL", value: "xl" },
169
+ { title: "2XL", value: "2xl" },
170
+ { title: "3XL", value: "3xl" },
171
+ { title: "4XL", value: "4xl" },
172
+ { title: "5XL", value: "5xl" },
173
+ { title: "6XL", value: "6xl" },
174
+ ],
175
+ },
176
+ initialValue: "3xl",
177
+ }),
178
+ defineField({
179
+ name: "font_weight",
180
+ title: "Font weight",
181
+ type: "string",
182
+ options: {
183
+ list: [
184
+ { title: "Normal", value: "400" },
185
+ { title: "Medium", value: "500" },
186
+ { title: "Bold", value: "700" },
187
+ { title: "Black", value: "900" },
188
+ ],
189
+ },
190
+ initialValue: "700",
191
+ }),
192
+ defineField({
193
+ name: "color",
194
+ title: "Text color",
195
+ type: "string",
196
+ description: "Hex or palette token",
197
+ initialValue: "#111111",
198
+ }),
199
+ defineField({
200
+ name: "text_style",
201
+ title: "Text style",
202
+ type: "string",
203
+ description:
204
+ "Outline renders hollow text via -webkit-text-stroke — the classic portfolio signature look.",
205
+ options: {
206
+ list: [
207
+ { title: "Solid", value: "solid" },
208
+ { title: "Outline (hollow)", value: "outline" },
209
+ { title: "Italic outline", value: "italic-outline" },
210
+ ],
211
+ },
212
+ initialValue: "solid",
213
+ }),
214
+ defineField({
215
+ name: "letter_spacing",
216
+ title: "Letter spacing (em)",
217
+ type: "number",
218
+ description: "Extra tracking between letters — in em units",
219
+ initialValue: 0,
220
+ validation: (Rule) => Rule.min(-0.1).max(0.5),
221
+ }),
222
+ defineField({
223
+ name: "text_transform",
224
+ title: "Text transform",
225
+ type: "string",
226
+ options: {
227
+ list: [
228
+ { title: "None", value: "none" },
229
+ { title: "UPPERCASE", value: "uppercase" },
230
+ { title: "lowercase", value: "lowercase" },
231
+ ],
232
+ },
233
+ initialValue: "uppercase",
234
+ }),
235
+
236
+ // ─── Layout ───────────────────────────────────────────────
237
+ defineField({
238
+ name: "gap",
239
+ title: "Gap between items (px)",
240
+ type: "number",
241
+ initialValue: 48,
242
+ validation: (Rule) => Rule.min(0).max(200),
243
+ }),
244
+ defineField({
245
+ name: "row_height",
246
+ title: "Row height (px)",
247
+ type: "number",
248
+ description: "Height of the marquee band. Determines image item height.",
249
+ initialValue: 120,
250
+ validation: (Rule) => Rule.min(24).max(600),
251
+ }),
252
+ defineField({
253
+ name: "padding_y",
254
+ title: "Vertical padding (px)",
255
+ type: "number",
256
+ initialValue: 16,
257
+ validation: (Rule) => Rule.min(0).max(200),
258
+ }),
259
+ defineField({
260
+ name: "background_color",
261
+ title: "Background color",
262
+ type: "string",
263
+ description: "Hex or palette token. Leave empty for transparent.",
264
+ }),
265
+
266
+ // ─── Standard block fields ────────────────────────────────
267
+ ...blockAnimationFields,
268
+ blockLayoutField,
269
+ ],
270
+ preview: {
271
+ select: {
272
+ direction: "direction",
273
+ speed: "speed",
274
+ items: "items",
275
+ },
276
+ prepare: ({
277
+ direction,
278
+ speed,
279
+ items,
280
+ }: {
281
+ direction?: string;
282
+ speed?: number;
283
+ items?: unknown[];
284
+ }) => {
285
+ const count = Array.isArray(items) ? items.length : 0;
286
+ const arrow = direction === "right" ? "←" : "→";
287
+ return {
288
+ title: `Marquee (${count} items ${arrow} ${speed ?? 60}px/s)`,
289
+ };
290
+ },
291
+ },
292
+ });