astro-tractstack 2.0.0-rc.57 → 2.0.0-rc.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.57",
3
+ "version": "2.0.0-rc.58",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -185,7 +185,11 @@ const authStatus = {
185
185
  ) : null
186
186
  }
187
187
 
188
- <SearchWrapper contentMap={fullContentMap} client:load />
188
+ {
189
+ !isStoryKeep && (
190
+ <SearchWrapper contentMap={fullContentMap} client:load />
191
+ )
192
+ }
189
193
 
190
194
  <script is:inline define:vars={{ sessionId }}>
191
195
  function initRememberMe() {
@@ -20,8 +20,8 @@ import type {
20
20
 
21
21
  interface BunnyVideoSetupProps {
22
22
  nodeId: string;
23
- params?: any;
24
- config?: BrandConfig;
23
+ params: any;
24
+ config: BrandConfig;
25
25
  }
26
26
 
27
27
  interface Chapter extends VideoMoment {
@@ -31,11 +31,15 @@ const featuredStory = contentMap.find(
31
31
  item.panes.length > 0 &&
32
32
  item.thumbSrc
33
33
  );
34
+ const bgColor = parsedOptions.bgColor || '';
34
35
  ---
35
36
 
36
37
  {
37
38
  featuredStory ? (
38
- <div class="mx-auto w-full max-w-7xl px-8 py-12">
39
+ <div
40
+ class="mx-auto w-full max-w-7xl px-8 py-12"
41
+ style={bgColor ? `background-color: ${bgColor}` : ''}
42
+ >
39
43
  <div class="grid grid-cols-1 items-center gap-x-16 gap-y-12 md:grid-cols-2">
40
44
  <div class="w-full">
41
45
  <a href={`/${featuredStory.slug}`} class="block">
@@ -6,11 +6,14 @@ import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/20/solid';
6
6
  import { fullContentMapStore, viewportKeyStore } from '@/stores/storykeep';
7
7
  import { getCtx } from '@/stores/nodes';
8
8
  import { cloneDeep } from '@/utils/helpers';
9
+ import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
9
10
  import type { PaneNode } from '@/types/compositorTypes';
11
+ import type { BrandConfig } from '@/types/tractstack';
10
12
 
11
13
  interface FeaturedArticleSetupProps {
12
- params?: Record<string, string>;
14
+ params: Record<string, string>;
13
15
  nodeId: string;
16
+ config: BrandConfig;
14
17
  }
15
18
 
16
19
  const comboboxItemStyles = `
@@ -25,6 +28,7 @@ const comboboxItemStyles = `
25
28
  const FeaturedArticleSetup = ({
26
29
  params,
27
30
  nodeId,
31
+ config,
28
32
  }: FeaturedArticleSetupProps) => {
29
33
  const $contentMap = useStore(fullContentMapStore);
30
34
  const $viewportKey = useStore(viewportKeyStore);
@@ -52,6 +56,7 @@ const FeaturedArticleSetup = ({
52
56
  const [isPanelOpen, setIsPanelOpen] = useState(false);
53
57
  const [selectedSlug, setSelectedSlug] = useState(initialSlug);
54
58
  const [query, setQuery] = useState(initialStory?.title || '');
59
+ const [bgColor, setBgColor] = useState(params?.bgColor || '');
55
60
 
56
61
  const selectedStory = useMemo(
57
62
  () => availableStories.find((story) => story.slug === selectedSlug),
@@ -81,10 +86,20 @@ const FeaturedArticleSetup = ({
81
86
  ...paneNode,
82
87
  codeHookTarget: 'featured-article',
83
88
  codeHookPayload: {
84
- options: JSON.stringify({ slug: selectedSlug }),
89
+ options: JSON.stringify({
90
+ slug: selectedSlug,
91
+ bgColor: bgColor,
92
+ }),
85
93
  },
94
+ bgColour: bgColor || undefined,
86
95
  isChanged: true,
87
96
  };
97
+
98
+ // If bgColor is empty, remove the property
99
+ if (!bgColor) {
100
+ delete updatedNode.bgColour;
101
+ }
102
+
88
103
  ctx.modifyNodes([updatedNode]);
89
104
  }
90
105
  };
@@ -96,7 +111,7 @@ const FeaturedArticleSetup = ({
96
111
  }
97
112
  const timeoutId = setTimeout(updatePaneNode, 500);
98
113
  return () => clearTimeout(timeoutId);
99
- }, [selectedSlug]);
114
+ }, [selectedSlug, bgColor]);
100
115
 
101
116
  const handleSelection = (details: { value: string[] }) => {
102
117
  const slug = details.value[0] || '';
@@ -268,6 +283,26 @@ const FeaturedArticleSetup = ({
268
283
  </Combobox.Root>
269
284
  </div>
270
285
 
286
+ <div className="rounded-lg bg-white p-4 shadow">
287
+ <div className="border-b border-gray-200 pb-4">
288
+ <h3 className="text-lg font-bold text-gray-900">Display Settings</h3>
289
+ </div>
290
+ <div className="space-y-4 pt-4">
291
+ <div>
292
+ <ColorPickerCombo
293
+ title="Background Color"
294
+ defaultColor={bgColor}
295
+ onColorChange={(color: string) => setBgColor(color)}
296
+ config={config!}
297
+ allowNull={true}
298
+ />
299
+ <p className="mt-1 text-xs text-gray-500">
300
+ Set a background color for the featured article section
301
+ </p>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
271
306
  {selectedStory && (
272
307
  <div className="rounded-lg bg-white p-4 shadow">
273
308
  <h3 className="border-b border-gray-200 pb-2 text-lg font-bold">
@@ -62,9 +62,14 @@ const sortedStories = [...filteredStories].sort((a, b) => {
62
62
  // The initial display
63
63
  const initialStories = sortedStories.slice(0, pageSize);
64
64
  const totalPages = Math.ceil(sortedStories.length / pageSize);
65
+
66
+ const bgColor = parsedOptions.bgColor || '';
65
67
  ---
66
68
 
67
- <div class="mx-auto max-w-7xl p-4 py-12">
69
+ <div
70
+ class="mx-auto max-w-7xl p-4 py-12"
71
+ style={bgColor ? `background-color: ${bgColor}` : ''}
72
+ >
68
73
  {
69
74
  initialStories.length === 0 && (
70
75
  <div class="rounded-lg bg-gray-50 px-4 py-12 text-center">
@@ -9,24 +9,10 @@ import type { PaneNode } from '@/types/compositorTypes';
9
9
 
10
10
  const PER_PAGE = 20;
11
11
 
12
- // V2 Analytics Data Structure
13
- interface StoryfragmentAnalytics {
14
- id: string;
15
- total_actions: number;
16
- unique_visitors: number;
17
- last_24h_actions: number;
18
- last_7d_actions: number;
19
- last_28d_actions: number;
20
- last_24h_unique_visitors: number;
21
- last_7d_unique_visitors: number;
22
- last_28d_unique_visitors: number;
23
- total_leads: number;
24
- }
25
-
26
12
  interface ListContentSetupProps {
27
13
  params?: Record<string, string>;
28
14
  nodeId: string;
29
- config?: BrandConfig;
15
+ config: BrandConfig;
30
16
  }
31
17
 
32
18
  const ListContentSetup = ({
@@ -34,17 +20,9 @@ const ListContentSetup = ({
34
20
  nodeId,
35
21
  config,
36
22
  }: ListContentSetupProps) => {
37
- const [analyticsData, setAnalyticsData] = useState<
38
- Record<string, StoryfragmentAnalytics>
39
- >({});
40
- const [isAnalyticsLoading, setIsAnalyticsLoading] = useState(true);
41
23
  const $contentMap = useStore(fullContentMapStore);
42
-
43
24
  const [isPanelOpen, setIsPanelOpen] = useState(false);
44
25
 
45
- const [selectedMode, setSelectedMode] = useState(
46
- params?.defaultMode || 'recent'
47
- );
48
26
  const [excludedIds, setExcludedIds] = useState<string[]>(
49
27
  params?.excludedIds ? params.excludedIds.split(',') : []
50
28
  );
@@ -89,47 +67,6 @@ const ListContentSetup = ({
89
67
  }
90
68
  });
91
69
 
92
- const fetchAnalyticsData = async () => {
93
- try {
94
- setIsAnalyticsLoading(true);
95
- // Updated to use V2 API endpoint
96
- const response = await fetch('/api/v1/analytics/storyfragments', {
97
- headers: {
98
- 'X-Tenant-ID': window.TRACTSTACK_CONFIG?.tenantId || 'default',
99
- },
100
- });
101
-
102
- if (!response.ok) {
103
- throw new Error(`HTTP error! status: ${response.status}`);
104
- }
105
-
106
- const analyticsArray = await response.json();
107
-
108
- // Transform array to a map keyed by ID for easier lookup
109
- // V2 API returns array directly, not wrapped in a success/data structure
110
- const analyticsById = Array.isArray(analyticsArray)
111
- ? analyticsArray.reduce(
112
- (
113
- acc: Record<string, StoryfragmentAnalytics>,
114
- item: StoryfragmentAnalytics
115
- ) => {
116
- acc[item.id] = item;
117
- return acc;
118
- },
119
- {}
120
- )
121
- : {};
122
-
123
- setAnalyticsData(analyticsById);
124
- } catch (error) {
125
- console.error('Error fetching analytics data:', error);
126
- // Set empty analytics on error to prevent blocking the UI
127
- setAnalyticsData({});
128
- } finally {
129
- setIsAnalyticsLoading(false);
130
- }
131
- };
132
-
133
70
  const topics = Array.from(topicMap.entries())
134
71
  .map(([name, { count, pageIds }]) => ({
135
72
  name,
@@ -147,12 +84,8 @@ const ListContentSetup = ({
147
84
  );
148
85
  });
149
86
 
87
+ // Always sort by most recent
150
88
  const sortedPages = [...filteredPages].sort((a, b) => {
151
- if (selectedMode === 'popular') {
152
- const aViews = analyticsData[a.id]?.total_actions || 0;
153
- const bViews = analyticsData[b.id]?.total_actions || 0;
154
- return bViews - aViews;
155
- }
156
89
  const bDate = b.changed ? new Date(b.changed) : new Date(0);
157
90
  const aDate = a.changed ? new Date(a.changed) : new Date(0);
158
91
  return bDate.getTime() - aDate.getTime();
@@ -174,7 +107,6 @@ const ListContentSetup = ({
174
107
  codeHookTarget: 'list-content',
175
108
  codeHookPayload: {
176
109
  options: JSON.stringify({
177
- defaultMode: selectedMode,
178
110
  excludedIds: excludedIds.join(','),
179
111
  topics: selectedTopics.join(','),
180
112
  pageSize: pageSize,
@@ -195,10 +127,6 @@ const ListContentSetup = ({
195
127
  }
196
128
  };
197
129
 
198
- useEffect(() => {
199
- fetchAnalyticsData();
200
- }, []);
201
-
202
130
  useEffect(() => {
203
131
  if (isInitialMount.current) {
204
132
  isInitialMount.current = false;
@@ -210,7 +138,7 @@ const ListContentSetup = ({
210
138
  }, 500);
211
139
 
212
140
  return () => clearTimeout(timeoutId);
213
- }, [selectedMode, excludedIds, selectedTopics, pageSize, bgColor]);
141
+ }, [excludedIds, selectedTopics, pageSize, bgColor]);
214
142
 
215
143
  // Toggle a page's exclusion status
216
144
  const toggleExclude = (id: string) => {
@@ -336,7 +264,6 @@ const ListContentSetup = ({
336
264
  );
337
265
  }
338
266
 
339
- if (isAnalyticsLoading) return null;
340
267
  return (
341
268
  <div className="w-full space-y-6 bg-slate-50 p-6">
342
269
  <div className="flex items-center justify-between">
@@ -377,29 +304,6 @@ const ListContentSetup = ({
377
304
  </select>
378
305
  </div>
379
306
 
380
- <div>
381
- <label
382
- htmlFor="sort-mode"
383
- className="block text-sm font-bold text-gray-700"
384
- >
385
- Default sort order
386
- </label>
387
- <select
388
- id="sort-mode"
389
- name="sort-mode"
390
- className="mt-1 block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-cyan-600 focus:outline-none focus:ring-cyan-600 sm:text-sm"
391
- value={selectedMode}
392
- onChange={(e) => setSelectedMode(e.target.value)}
393
- >
394
- <option value="recent">Most Recent</option>
395
- <option value="popular">Most Popular</option>
396
- </select>
397
- <p className="mt-1 text-xs text-gray-500">
398
- Note: Users can toggle between views regardless of the default
399
- setting
400
- </p>
401
- </div>
402
-
403
307
  <div>
404
308
  <ColorPickerCombo
405
309
  title="Background Color"
@@ -533,7 +437,6 @@ const ListContentSetup = ({
533
437
  <div className="divide-y divide-gray-200">
534
438
  {paginatedPages.map((page) => {
535
439
  const isExcluded = excludedIds.includes(page.id);
536
- const analytics = analyticsData[page.id];
537
440
 
538
441
  return (
539
442
  <div key={page.id} className="flex items-center p-4">
@@ -586,19 +489,10 @@ const ListContentSetup = ({
586
489
  </div>
587
490
  )}
588
491
  <div className="mt-1 flex items-center text-xs text-gray-500">
589
- {analytics && (
590
- <>
591
- <span>{analytics.total_actions} views</span>
592
- {page.changed && (
593
- <>
594
- <span className="mx-2">•</span>
595
- <span>
596
- Updated{' '}
597
- {new Date(page.changed).toLocaleDateString()}
598
- </span>
599
- </>
600
- )}
601
- </>
492
+ {page.changed && (
493
+ <span>
494
+ Updated {new Date(page.changed).toLocaleDateString()}
495
+ </span>
602
496
  )}
603
497
  </div>
604
498
  </div>
@@ -161,18 +161,19 @@ const Pane = memo(
161
161
  <FeaturedArticleSetup
162
162
  nodeId={props.nodeId}
163
163
  params={codeHookParams}
164
+ config={props.config!}
164
165
  />
165
166
  ) : codeHookPayload && codeHookTarget === 'list-content' ? (
166
167
  <ListContentSetup
167
168
  nodeId={props.nodeId}
168
169
  params={codeHookParams}
169
- config={props.config}
170
+ config={props.config!}
170
171
  />
171
172
  ) : codeHookPayload && codeHookTarget === 'bunny-video' ? (
172
173
  <BunnyVideoSetup
173
174
  nodeId={props.nodeId}
174
175
  params={codeHookParams}
175
- config={props.config}
176
+ config={props.config!}
176
177
  />
177
178
  ) : codeHookPayload && codeHookTarget ? (
178
179
  <CodeHookContainer
@@ -195,5 +195,14 @@ export const PaneSnapshotGenerator = ({
195
195
  generateSnapshot();
196
196
  }, [id, htmlString, isGenerating, onComplete, onError, config, outputWidth]);
197
197
 
198
+ // Show spinner while generating
199
+ if (isGenerating) {
200
+ return (
201
+ <div className="flex h-24 items-center justify-center">
202
+ <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-cyan-600"></div>
203
+ </div>
204
+ );
205
+ }
206
+
198
207
  return null;
199
208
  };
@@ -14,7 +14,9 @@ import {
14
14
  type SnapshotData,
15
15
  } from '@/components/compositor/preview/PaneSnapshotGenerator';
16
16
  import { createEmptyStorykeep } from '@/utils/compositor/nodesHelper';
17
+ import { tailwindToHex } from '@/utils/compositor/tailwindColors';
17
18
  import { getTemplateVisualBreakPane } from '@/utils/compositor/TemplatePanes';
19
+ import { SvgBreaks } from '@/constants/shapes';
18
20
  import {
19
21
  PaneAddMode,
20
22
  type PaneNode,
@@ -243,8 +245,16 @@ const AddPaneBreakPanel = ({
243
245
  }
244
246
  }
245
247
 
246
- template.bgColour = belowColor;
247
- const svgFill = aboveColor === belowColor ? 'black' : aboveColor;
248
+ const shapeName = `kCz${variant}`;
249
+ const isFlipped = SvgBreaks[shapeName]?.flipped || false;
250
+ template.bgColour = tailwindToHex(
251
+ isFlipped ? aboveColor : belowColor,
252
+ null
253
+ );
254
+ const svgFill = tailwindToHex(
255
+ isFlipped ? belowColor : aboveColor === belowColor ? 'black' : aboveColor,
256
+ null
257
+ );
248
258
 
249
259
  if (template.bgPane) {
250
260
  if (template.bgPane.type === 'visual-break') {
@@ -7,6 +7,8 @@ import { getTemplateVisualBreakPane } from '@/utils/compositor/TemplatePanes';
7
7
  import { fullContentMapStore } from '@/stores/storykeep';
8
8
  import type { NodesContext } from '@/stores/nodes';
9
9
  import { findUniqueSlug } from '@/utils/helpers';
10
+ import { tailwindToHex } from '@/utils/compositor/tailwindColors';
11
+ import { SvgBreaks } from '@/constants/shapes';
10
12
  import type { StoryFragmentNode, TemplatePane } from '@/types/compositorTypes';
11
13
 
12
14
  // Layout options with IDs, labels, and descriptions
@@ -121,24 +123,36 @@ const PageCreationSpecial = ({
121
123
  const bgColor = breakVariant?.odd ? 'white' : 'gray-50';
122
124
  const fillColor = breakVariant?.odd ? 'gray-50' : 'white';
123
125
 
126
+ const shapeName = `kCz${selectedBreak}`;
127
+ const isFlipped = SvgBreaks[shapeName]?.flipped || false;
128
+
129
+ const finalBgColor = tailwindToHex(
130
+ isFlipped ? fillColor : bgColor,
131
+ null
132
+ );
133
+ const finalFillColor = tailwindToHex(
134
+ isFlipped ? bgColor : fillColor,
135
+ null
136
+ );
137
+
124
138
  // 2. Create Visual Break pane
125
139
  const visualBreakTemplate = getTemplateVisualBreakPane(selectedBreak);
126
140
  visualBreakTemplate.id = ulid();
127
141
  visualBreakTemplate.title = 'Visual Break';
128
142
  visualBreakTemplate.slug = `${storyfragment.slug}-visual-break`;
129
- visualBreakTemplate.bgColour = bgColor;
143
+ visualBreakTemplate.bgColour = finalBgColor;
130
144
 
131
145
  // Configure the SVG fill color
132
146
  if (visualBreakTemplate.bgPane) {
133
147
  if (visualBreakTemplate.bgPane.type === 'visual-break') {
134
148
  if (visualBreakTemplate.bgPane.breakDesktop) {
135
- visualBreakTemplate.bgPane.breakDesktop.svgFill = fillColor;
149
+ visualBreakTemplate.bgPane.breakDesktop.svgFill = finalFillColor;
136
150
  }
137
151
  if (visualBreakTemplate.bgPane.breakTablet) {
138
- visualBreakTemplate.bgPane.breakTablet.svgFill = fillColor;
152
+ visualBreakTemplate.bgPane.breakTablet.svgFill = finalFillColor;
139
153
  }
140
154
  if (visualBreakTemplate.bgPane.breakMobile) {
141
- visualBreakTemplate.bgPane.breakMobile.svgFill = fillColor;
155
+ visualBreakTemplate.bgPane.breakMobile.svgFill = finalFillColor;
142
156
  }
143
157
  }
144
158
  }
@@ -164,7 +178,10 @@ const PageCreationSpecial = ({
164
178
  isDecorative: false,
165
179
  parentId: nodeId,
166
180
  // For complete-home layout, match the background color with the visual break
167
- bgColour: selectedLayout === 'complete-home' ? 'gray-50' : 'white',
181
+ bgColour: tailwindToHex(
182
+ selectedLayout === 'complete-home' ? 'gray-50' : 'white',
183
+ null
184
+ ),
168
185
  codeHookTarget: 'list-content',
169
186
  codeHookPayload: {
170
187
  options: JSON.stringify({
@@ -12,6 +12,8 @@ import {
12
12
  getIntroDesign,
13
13
  getWithArtpackImageDesign,
14
14
  } from '@/utils/compositor/templateMarkdownStyles';
15
+ import { tailwindToHex } from '@/utils/compositor/tailwindColors';
16
+ import { SvgBreaks } from '@/constants/shapes';
15
17
  import {
16
18
  parsePageMarkdown,
17
19
  validatePageMarkdown,
@@ -243,8 +245,21 @@ export const PageCreationPreview = ({
243
245
  ? design.visualBreaks.odd()
244
246
  : design.visualBreaks.even();
245
247
 
246
- breakTemplate.bgColour = aboveColor;
247
- const svgFill = belowColor;
248
+ const breakData = breakTemplate.bgPane?.breakDesktop;
249
+ const shapeName = breakData
250
+ ? `${breakData.collection}${breakData.image}`
251
+ : '';
252
+ const isFlipped =
253
+ (shapeName && SvgBreaks[shapeName]?.flipped) || false;
254
+
255
+ breakTemplate.bgColour = tailwindToHex(
256
+ isFlipped ? belowColor : aboveColor,
257
+ null
258
+ );
259
+ const svgFill = tailwindToHex(
260
+ isFlipped ? aboveColor : belowColor,
261
+ null
262
+ );
248
263
  if (breakTemplate.bgPane) {
249
264
  if (breakTemplate.bgPane.breakDesktop) {
250
265
  breakTemplate.bgPane.breakDesktop.svgFill = svgFill;
@@ -155,25 +155,23 @@ const StyleBreakPanel = ({ node, parentNode, config }: BasePanelProps) => {
155
155
  </div>
156
156
 
157
157
  <div className="space-y-4">
158
- <div className="grid grid-cols-2 gap-4">
159
- <ColorPickerCombo
160
- title="Shape Color"
161
- defaultColor={settings.svgFill}
162
- onColorChange={(color: string) =>
163
- setSettings((prev) => ({ ...prev, svgFill: color }))
164
- }
165
- config={config!}
166
- />
167
- <ColorPickerCombo
168
- title="Background Color"
169
- defaultColor={settings.bgColor}
170
- onColorChange={(color: string) =>
171
- setSettings((prev) => ({ ...prev, bgColor: color }))
172
- }
173
- config={config!}
174
- allowNull={true}
175
- />
176
- </div>
158
+ <ColorPickerCombo
159
+ title="Shape Color"
160
+ defaultColor={settings.svgFill}
161
+ onColorChange={(color: string) =>
162
+ setSettings((prev) => ({ ...prev, svgFill: color }))
163
+ }
164
+ config={config!}
165
+ />
166
+ <ColorPickerCombo
167
+ title="Background Color"
168
+ defaultColor={settings.bgColor}
169
+ onColorChange={(color: string) =>
170
+ setSettings((prev) => ({ ...prev, bgColor: color }))
171
+ }
172
+ config={config!}
173
+ allowNull={true}
174
+ />
177
175
  </div>
178
176
  </div>
179
177
  );
@@ -236,7 +236,7 @@ const ColorPickerCombo = ({
236
236
  <Combobox.Control ref={comboboxRef}>
237
237
  <div className="relative">
238
238
  <Combobox.Input
239
- className="border-mydarkgrey focus:border-myblue focus:ring-myblue xs:text-sm w-full max-w-48 rounded-md py-2 pl-3 pr-10 shadow-sm"
239
+ className="border-mydarkgrey focus:border-myblue focus:ring-myblue xs:text-sm w-full max-w-xl rounded-md py-2 pl-3 pr-10 shadow-sm"
240
240
  placeholder="Search Tailwind colors..."
241
241
  autoComplete="off"
242
242
  />
@@ -12,6 +12,8 @@ import StringInput from '@/components/form/StringInput';
12
12
  import EnumSelect from '@/components/form/EnumSelect';
13
13
  import ActionBuilderField from '@/components/form/ActionBuilderField';
14
14
  import UnsavedChangesBar from '@/components/form/UnsavedChangesBar';
15
+ import ArrowUpIcon from '@heroicons/react/24/outline/ArrowUpIcon';
16
+ import ArrowDownIcon from '@heroicons/react/24/outline/ArrowDownIcon';
15
17
  import type {
16
18
  MenuNode,
17
19
  MenuNodeState,
@@ -88,6 +90,26 @@ export default function MenuForm({
88
90
  formState.updateField('menuLinks', newState.menuLinks);
89
91
  };
90
92
 
93
+ const handleMoveUp = (index: number) => {
94
+ if (index === 0) return;
95
+ const newMenuLinks = [...formState.state.menuLinks];
96
+ [newMenuLinks[index - 1], newMenuLinks[index]] = [
97
+ newMenuLinks[index],
98
+ newMenuLinks[index - 1],
99
+ ];
100
+ formState.updateField('menuLinks', newMenuLinks);
101
+ };
102
+
103
+ const handleMoveDown = (index: number) => {
104
+ if (index === formState.state.menuLinks.length - 1) return;
105
+ const newMenuLinks = [...formState.state.menuLinks];
106
+ [newMenuLinks[index], newMenuLinks[index + 1]] = [
107
+ newMenuLinks[index + 1],
108
+ newMenuLinks[index],
109
+ ];
110
+ formState.updateField('menuLinks', newMenuLinks);
111
+ };
112
+
91
113
  const handleCancel = () => {
92
114
  onClose?.(false);
93
115
  };
@@ -155,13 +177,39 @@ export default function MenuForm({
155
177
  <h4 className="text-sm font-bold text-gray-900">
156
178
  Link {index + 1}
157
179
  </h4>
158
- <button
159
- type="button"
160
- onClick={() => handleRemoveLink(index)}
161
- className="text-sm font-bold text-red-600 hover:text-red-800"
162
- >
163
- Remove
164
- </button>
180
+ <div className="flex items-center gap-2">
181
+ {/* Reorder buttons */}
182
+ <div className="flex items-center gap-1">
183
+ {index > 0 && (
184
+ <button
185
+ type="button"
186
+ onClick={() => handleMoveUp(index)}
187
+ className="rounded p-1 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
188
+ title="Move up"
189
+ >
190
+ <ArrowUpIcon className="h-4 w-4" />
191
+ </button>
192
+ )}
193
+ {index < formState.state.menuLinks.length - 1 && (
194
+ <button
195
+ type="button"
196
+ onClick={() => handleMoveDown(index)}
197
+ className="rounded p-1 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
198
+ title="Move down"
199
+ >
200
+ <ArrowDownIcon className="h-4 w-4" />
201
+ </button>
202
+ )}
203
+ </div>
204
+ {/* Remove button */}
205
+ <button
206
+ type="button"
207
+ onClick={() => handleRemoveLink(index)}
208
+ className="text-sm font-bold text-red-600 hover:text-red-800"
209
+ >
210
+ Remove
211
+ </button>
212
+ </div>
165
213
  </div>
166
214
 
167
215
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">