includio-cms 0.0.67 → 0.0.69

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 (41) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/ROADMAP.md +73 -0
  3. package/dist/admin/client/entry/entry-form.svelte +1 -1
  4. package/dist/admin/client/entry/entry-form.svelte.d.ts +1 -1
  5. package/dist/admin/client/entry/entry.svelte +17 -6
  6. package/dist/admin/client/entry/hybrid/hybrid-context.svelte.d.ts +1 -0
  7. package/dist/admin/components/fields/array-field.svelte +126 -71
  8. package/dist/admin/components/fields/relation-field.svelte +6 -10
  9. package/dist/admin/components/layout/nav-search.svelte +43 -31
  10. package/dist/admin/remote/media.remote.js +3 -3
  11. package/dist/admin/utils/arrayMove.d.ts +5 -0
  12. package/dist/admin/utils/arrayMove.js +12 -0
  13. package/dist/cms/runtime/types.d.ts +8 -0
  14. package/dist/core/server/generator/fields.js +9 -3
  15. package/dist/core/server/generator/generator.js +4 -7
  16. package/dist/core/server/generator/utils.d.ts +1 -0
  17. package/dist/core/server/generator/utils.js +6 -0
  18. package/dist/core/server/media/styles/operations/generateDefaultStyles.d.ts +1 -0
  19. package/dist/core/server/media/styles/operations/generateDefaultStyles.js +17 -16
  20. package/dist/core/server/media/styles/sharp/generateImageStyle.js +15 -5
  21. package/dist/core/server/utils/sanitizeRichText.d.ts +1 -0
  22. package/dist/core/server/utils/sanitizeRichText.js +67 -0
  23. package/dist/sveltekit/components/image.svelte +11 -2
  24. package/dist/sveltekit/components/preview.svelte +22 -14
  25. package/dist/sveltekit/components/preview.svelte.d.ts +38 -8
  26. package/dist/sveltekit/index.d.ts +1 -0
  27. package/dist/sveltekit/index.js +1 -0
  28. package/dist/sveltekit/utils/getLink.d.ts +7 -0
  29. package/dist/sveltekit/utils/getLink.js +32 -0
  30. package/dist/sveltekit/utils/index.d.ts +2 -0
  31. package/dist/sveltekit/utils/index.js +2 -0
  32. package/dist/sveltekit/utils/media.d.ts +3 -0
  33. package/dist/sveltekit/utils/media.js +6 -0
  34. package/dist/types/index.d.ts +8 -1
  35. package/dist/types/index.js +7 -0
  36. package/dist/updates/0.0.68/index.d.ts +2 -0
  37. package/dist/updates/0.0.68/index.js +21 -0
  38. package/dist/updates/0.0.69/index.d.ts +2 -0
  39. package/dist/updates/0.0.69/index.js +12 -0
  40. package/dist/updates/index.js +3 -1
  41. package/package.json +7 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,157 @@
1
+ # Changelog
2
+
3
+ All notable changes to includio-cms are documented here.
4
+ Generated from `src/lib/updates/` — do not edit manually.
5
+
6
+ ## 0.0.69 — 2026-02-17
7
+
8
+ DnD array reordering, nav-search batch fetch
9
+
10
+ ### Added
11
+ - Drag-and-drop reordering in array fields with arrayMove utility
12
+
13
+ ### Fixed
14
+ - Nav-search: batch-fetch data before rendering Command dialog — fixes missing data on open
15
+
16
+ ## 0.0.68 — 2026-02-11
17
+
18
+ Fix EXIF/blur/focal bugs, expand type exports, add utility functions
19
+
20
+ ### Added
21
+ - Export Entry, EntryType, CMSConfig, CollectionConfig, SingleConfig, FormConfig, FormSubmission, Language, Localized, MediaFileType, ImageStyle, MediaTag from includio-cms/types
22
+ - Add getLink() utility for resolving URL field data to strings
23
+ - Add isImageFieldData() / isVideoFieldData() type guards for media fields
24
+
25
+ ### Fixed
26
+ - Blur placeholder (LQIP) now removed after image loads — fixes visible blur behind transparent PNGs
27
+ - Focal point change now awaits style regeneration before returning — fixes stale/missing styles after save
28
+ - Explicit .rotate() in sharp pipeline — fixes upside-down WebP/JPEG when source has EXIF orientation
29
+ - Use oriented dimensions (post-EXIF) for focal crop calculation — fixes wrong crop on rotated images
30
+ - Codegen: media field type now generates ImageFieldData | VideoFieldData instead of any
31
+ - Codegen: hyphenated collection slugs now produce correct PascalCase (article-category → ArticleCategory)
32
+ - Codegen: generated interfaces now include id and slug fields
33
+ - Preview deep merge now preserves resolved references without url (e.g. category relations)
34
+
35
+ ## 0.0.67 — 2026-02-11
36
+
37
+ Image quality, LQIP, focal point, responsive srcset, auto-downscale
38
+
39
+ ### Added
40
+ - Image styles: configurable quality parameter for format output
41
+ - LQIP: auto-generated blur placeholder (base64 WebP) on image upload
42
+ - LQIP: lazy backfill for existing images without blur placeholder
43
+ - Image component: LQIP blur placeholder via background-image on img element
44
+ - Focal point picker in media file details
45
+ - Smart crop: styles with crop=true use focal point to calculate crop region
46
+ - Style cache invalidation on focal point change
47
+ - Default WebP/AVIF styles auto-generate 640/1024/1920w srcset variants
48
+ - Custom styles support srcset[] and sizes config
49
+ - Image component renders srcset + sizes on <source> elements
50
+ - Image component: sizes prop to override style-level sizes on <source> elements
51
+ - Skips srcset widths larger than original image
52
+ - CMSConfig: media.maxOriginalWidth / maxOriginalHeight settings
53
+ - Auto-downscale oversized images on upload (fit: inside)
54
+ - SVG and non-image files are not affected
55
+ - Auto-format expansion: styles without explicit format auto-expand to AVIF/WebP/original
56
+ - Eager background generation of default image styles on upload/replace/focal-point change
57
+
58
+ ### Fixed
59
+ - Case-insensitive SVG extension check in upload/downscale/LQIP
60
+ - Quality value clamped to 1-100 in generateImageStyle
61
+ - Silent errors replaced with console.warn for LQIP/downscale/file cleanup
62
+ - Remove hardcoded sizes: 100vw from default image styles
63
+ - Lazy load skeleton + error handling for hybrid layout imports
64
+ - AVIF <source> rendered before WebP for correct browser priority
65
+ - Cache-busting suffix in generated style filenames prevents stale CDN/browser cache
66
+
67
+ ### Breaking
68
+ - getImageStyles() now returns { styles, blurDataUrl } instead of plain styles record
69
+
70
+ ### Migration
71
+
72
+ ```sql
73
+ ALTER TABLE image_styles ADD COLUMN IF NOT EXISTS quality INTEGER;
74
+ ALTER TABLE media_file ADD COLUMN IF NOT EXISTS blur_data_url TEXT;
75
+ ALTER TABLE media_file ADD COLUMN IF NOT EXISTS focal_x REAL;
76
+ ALTER TABLE media_file ADD COLUMN IF NOT EXISTS focal_y REAL;
77
+ ```
78
+
79
+ ## 0.0.66 — 2025-02-10
80
+
81
+ Fix URL field validation for linked entries
82
+
83
+ ### Fixed
84
+ - URL field: linked entries no longer require manual URL per language to save
85
+
86
+ ## 0.0.65 — 2025-02-09
87
+
88
+ Move publish logic to entry table, replace folder-based media with tags
89
+
90
+ ### Added
91
+ - Account settings page with session management
92
+ - Redesigned sidebar with collapsible groups
93
+ - Tag-based media library replacing folder structure
94
+ - Unified UI components and styling
95
+
96
+ ### Breaking
97
+ - Media folders replaced with tags — existing folder assignments will be lost
98
+
99
+ ### Migration
100
+
101
+ ```sql
102
+ -- Move publish logic from entry_version to entry
103
+
104
+ ALTER TABLE entry ADD COLUMN published_at TIMESTAMP;
105
+ ALTER TABLE entry ADD COLUMN published_version_id UUID
106
+ REFERENCES entry_version(id) ON DELETE SET NULL;
107
+ ALTER TABLE entry ADD COLUMN published_by TEXT;
108
+
109
+ UPDATE entry e SET
110
+ published_version_id = latest.id,
111
+ published_at = first_pub.first_published_at,
112
+ published_by = latest.published_by
113
+ FROM (
114
+ SELECT DISTINCT ON (entry_id) id, entry_id, published_by
115
+ FROM entry_version
116
+ WHERE published_at IS NOT NULL AND published_at <= NOW()
117
+ ORDER BY entry_id, version_number DESC
118
+ ) latest
119
+ JOIN (
120
+ SELECT entry_id, MIN(published_at) as first_published_at
121
+ FROM entry_version
122
+ WHERE published_at IS NOT NULL AND published_at <= NOW()
123
+ GROUP BY entry_id
124
+ ) first_pub ON first_pub.entry_id = latest.entry_id
125
+ WHERE e.id = latest.entry_id;
126
+
127
+ UPDATE entry e SET
128
+ published_version_id = COALESCE(e.published_version_id, sub.id),
129
+ published_at = CASE WHEN e.published_at IS NULL THEN sub.published_at ELSE e.published_at END,
130
+ published_by = COALESCE(e.published_by, sub.published_by)
131
+ FROM (
132
+ SELECT DISTINCT ON (entry_id) id, entry_id, published_at, published_by
133
+ FROM entry_version
134
+ WHERE published_at IS NOT NULL AND published_at > NOW()
135
+ ORDER BY entry_id, published_at ASC
136
+ ) sub
137
+ WHERE e.id = sub.entry_id AND e.published_version_id IS NULL;
138
+
139
+ -- Replace folder-based media with tag-based media
140
+
141
+ CREATE TABLE IF NOT EXISTS media_tag (
142
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
143
+ name TEXT NOT NULL UNIQUE,
144
+ color TEXT NOT NULL DEFAULT '#3b82f6',
145
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
146
+ );
147
+
148
+ CREATE TABLE IF NOT EXISTS media_file_tag (
149
+ file_id UUID NOT NULL REFERENCES media_file(id) ON DELETE CASCADE,
150
+ tag_id UUID NOT NULL REFERENCES media_tag(id) ON DELETE CASCADE,
151
+ PRIMARY KEY (file_id, tag_id)
152
+ );
153
+
154
+ ALTER TABLE media_file DROP COLUMN IF EXISTS folder_id;
155
+
156
+ DROP TABLE IF EXISTS media_folder;
157
+ ```
package/ROADMAP.md ADDED
@@ -0,0 +1,73 @@
1
+ # Roadmap
2
+
3
+ > `- [ ]` planned | `- [~]` in-progress | `- [x]` done
4
+ > `[feature]` `[fix]` `[breaking]` `[chore]`
5
+ > `[P0]` critical | `[P1]` important | `[P2]` nice-to-have
6
+ > `<!-- files: path/to/file.ts -->` optionally linked files
7
+ >
8
+ > **Versioning:** 0.0.69 is the last `0.0.x` release. From 0.1.0 onward: `0.MINOR.0` = features/changes, `0.MINOR.PATCH` = fixes.
9
+
10
+ ## 0.0.69 _(last 0.0.x)_
11
+
12
+ - [x] `[feature]` `[P1]` DnD reordering in array fields + arrayMove utility <!-- files: src/cms/fields/array-field -->
13
+ - [x] `[fix]` `[P1]` Nav-search: batch-fetch data before rendering Command dialog <!-- files: src/cms/nav-search -->
14
+
15
+ ## 0.1.0 — Stabilization
16
+
17
+ - [ ] `[fix]` `[P0]` Collection table pagination — server-side, persistent page state, archived tab <!-- files: src/lib/admin/client/collection/collection-entries.svelte, src/lib/admin/client/collection/table-pagination.svelte -->
18
+ - [ ] `[fix]` `[P0]` Language switcher — reactive globally without reload, remove hardcoded en/pl <!-- files: src/lib/admin/state/interface-language.svelte.ts, src/lib/admin/components/layout/header-actions.svelte -->
19
+ - [ ] `[fix]` `[P0]` User account section — email change, aria-pressed on prefs, avatar upload, clean up 2FA stub <!-- files: src/lib/admin/client/account/ -->
20
+ - [ ] `[fix]` `[P0]` Media tag counts always 0 — pass actual files to TagSidebar <!-- files: src/lib/admin/components/media/media-library.svelte, src/lib/admin/components/media/tag-sidebar.svelte -->
21
+ - [ ] `[fix]` `[P0]` Hybrid target — don't render `data-hybrid-path` for non-logged-in users <!-- files: src/lib/sveltekit/components/hybrid-target.svelte -->
22
+
23
+ ## 0.1.1 — Input integrity
24
+
25
+ - [ ] `[fix]` `[P1]` Input constraints UI — HTML maxlength, character counter, pattern feedback <!-- files: src/lib/admin/components/fields/text-field.svelte -->
26
+ - [ ] `[fix]` `[P1]` Array field maxItems — disable Add button when max reached <!-- files: src/lib/admin/components/fields/array-field.svelte -->
27
+ - [ ] `[feature]` `[P1]` Array field fixed length — fixed item count, no add/remove, reorder only
28
+ - [ ] `[feature]` `[P1]` Field constraint info display — show constraints before validation error (WCAG/ATAG)
29
+
30
+ ## 0.2.0 — Plugin system
31
+
32
+ - [ ] `[feature]` `[P0]` Wire plugin hooks into CRUD operations (before/afterCreate, Update, Delete) <!-- files: src/lib/types/plugins.ts, src/lib/core/server/entries/operations/ -->
33
+ - [ ] `[feature]` `[P0]` Plugin registration API — public surface for external plugins
34
+ - [ ] `[chore]` `[P1]` Plugin system documentation
35
+
36
+ ## 0.2.1 — CMS API improvements
37
+
38
+ - [ ] `[feature]` `[P1]` Server-side pagination API (formalize 0.1.0 fix)
39
+ - [ ] `[feature]` `[P1]` Improved type generation <!-- files: src/lib/core/server/generator/ -->
40
+ - [ ] `[feature]` `[P1]` Proper filtering API (SQL-level, not JS post-query)
41
+
42
+ ## 0.3.0 — Admin experience
43
+
44
+ - [ ] `[feature]` `[P1]` Admin overlay — built-in admin bar on site (quick edit, preview, audit)
45
+ - [ ] `[feature]` `[P1]` Media gallery virtualization/pagination <!-- files: src/lib/admin/components/media/files-list.svelte -->
46
+ - [ ] `[feature]` `[P2]` Image styles UI — preview generated variants in editor
47
+
48
+ ## 0.3.1 — SEO module
49
+
50
+ - [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
51
+ - [ ] `[feature]` `[P1]` Global SEO settings
52
+ - [ ] `[feature]` `[P1]` Dedicated frontend SEO components <!-- files: src/lib/sveltekit/components/seo.svelte -->
53
+ - [ ] `[feature]` `[P2]` Sitemap generation
54
+
55
+ ## 0.4.0 — WCAG/ATAG compliance
56
+
57
+ - [ ] `[chore]` `[P0]` Full WCAG/ATAG audit
58
+ - [ ] `[feature]` `[P0]` Accessibility rework based on audit findings
59
+
60
+ ## Security hardening
61
+
62
+ - [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
63
+ - [ ] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware
64
+ - [ ] `[feature]` `[P1]` CSRF protection — tokens for mutating operations
65
+ - [ ] `[feature]` `[P1]` Rate limiting — API/auth endpoints
66
+ - [ ] `[chore]` `[P1]` Security audit — review `{@html}` usage, innerHTML, injection vectors
67
+ - [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
68
+
69
+ ## Backlog
70
+
71
+ - [ ] `[feature]` `[P2]` Alternative richtext editor — Word-like mode, single richtext field instead of blocks
72
+ - [ ] `[chore]` `[P2]` Caching/performance layer (scope TBD)
73
+ - [ ] `[feature]` `[P2]` API/CLI for configuration (setup DX for less technical users)
@@ -4,7 +4,7 @@
4
4
  import type { SuperForm } from 'sveltekit-superforms';
5
5
 
6
6
  type Props = {
7
- form: SuperForm<any>;
7
+ form: SuperForm<Record<string, unknown>>;
8
8
  entry: RawEntry;
9
9
  focusedPath?: string | null;
10
10
  onPathSelect?: (path: string) => void;
@@ -1,7 +1,7 @@
1
1
  import type { RawEntry } from '../../../types/entries.js';
2
2
  import type { SuperForm } from 'sveltekit-superforms';
3
3
  type Props = {
4
- form: SuperForm<any>;
4
+ form: SuperForm<Record<string, unknown>>;
5
5
  entry: RawEntry;
6
6
  focusedPath?: string | null;
7
7
  onPathSelect?: (path: string) => void;
@@ -290,8 +290,18 @@
290
290
 
291
291
  let sizePreset: SizePreset = $state('responsive');
292
292
 
293
+ function getOriginFromUrl(url: string): string {
294
+ try {
295
+ return new URL(url, window.location.origin).origin;
296
+ } catch {
297
+ return window.location.origin;
298
+ }
299
+ }
300
+
301
+ const previewOrigin = collection.previewUrl ? getOriginFromUrl(collection.previewUrl) : window.location.origin;
302
+
293
303
  const updatePreview = useDebounce(
294
- async (window: Window, form: SuperForm<any>) => {
304
+ async (window: Window, form: SuperForm<Record<string, unknown>>) => {
295
305
  const data = await form.validateForm();
296
306
 
297
307
  if (data.valid) {
@@ -306,7 +316,7 @@
306
316
  type: 'preview-update',
307
317
  data: updatedData
308
318
  },
309
- '*'
319
+ previewOrigin
310
320
  );
311
321
  }
312
322
  },
@@ -340,7 +350,7 @@
340
350
  // Send hybrid mode state first
341
351
  previewIframe.contentWindow.postMessage(
342
352
  { type: 'hybrid-mode-enable', enabled: hybridContext.mode === 'hybrid' },
343
- '*'
353
+ previewOrigin
344
354
  );
345
355
 
346
356
  // Then send form data
@@ -353,7 +363,7 @@
353
363
  });
354
364
  previewIframe.contentWindow.postMessage(
355
365
  { type: 'preview-update', data: updatedData },
356
- '*'
366
+ previewOrigin
357
367
  );
358
368
  }
359
369
  }
@@ -369,6 +379,7 @@
369
379
  // Listen for messages from iframe
370
380
  onMount(() => {
371
381
  function handleMessage(e: MessageEvent) {
382
+ if (e.origin !== previewOrigin) return;
372
383
  // Click on preview element - debounced to prevent scroll jumping
373
384
  if (e.data.type === 'hybrid-focus' && e.data.path) {
374
385
  debouncedSetFocusPath(e.data.path);
@@ -387,7 +398,7 @@
387
398
  if (previewIframe?.contentWindow) {
388
399
  previewIframe.contentWindow.postMessage(
389
400
  { type: 'hybrid-highlight', path: hybridContext.focusedPath },
390
- '*'
401
+ previewOrigin
391
402
  );
392
403
  }
393
404
  });
@@ -400,7 +411,7 @@
400
411
  if (previewIframe?.contentWindow) {
401
412
  previewIframe.contentWindow.postMessage(
402
413
  { type: 'hybrid-mode-enable', enabled: hybridContext.mode === 'hybrid' },
403
- '*'
414
+ previewOrigin
404
415
  );
405
416
  }
406
417
  }
@@ -5,6 +5,7 @@ export declare function createHybridContext(): {
5
5
  enabled: boolean;
6
6
  toggle(): void;
7
7
  };
8
+ export type HybridContext = ReturnType<typeof createHybridContext>;
8
9
  export declare function getHybridContext(): {
9
10
  mode: HybridViewMode;
10
11
  focusedPath: string | null;
@@ -30,6 +30,10 @@
30
30
  import RequiredLabel from './required-label.svelte';
31
31
  import { cn } from '../../../utils.js';
32
32
  import BlockPickerModal from './block-picker-modal.svelte';
33
+ import GripVertical from '@tabler/icons-svelte/icons/grip-vertical';
34
+ import { droppable, draggable, dndState } from '@thisux/sveltednd';
35
+ import { flip } from 'svelte/animate';
36
+ import { arrayMove } from '../../utils/arrayMove.js';
33
37
 
34
38
  const contentLanguage = getContentLanguage();
35
39
  const interfaceLanguage = useInterfaceLanguage();
@@ -119,6 +123,17 @@
119
123
  });
120
124
  }
121
125
 
126
+ let dropProcessing = false;
127
+
128
+ function moveItem(from: number, to: number) {
129
+ if (!$value || from === to) return;
130
+ $value = arrayMove($value, from, to);
131
+
132
+ tick().then(() => {
133
+ openAndCloseOthers(to);
134
+ });
135
+ }
136
+
122
137
  function removeItem(index: number) {
123
138
  if (!$value) return;
124
139
 
@@ -208,80 +223,120 @@
208
223
  <Accordion.Root type="multiple" class="w-full space-y-4" bind:value={accordionOpenState}>
209
224
  {#if $value && $value.length > 0}
210
225
  {#each $value as item, index (item._id ?? index)}
211
- {#if $value[index].data && $value[index].slug}
212
- {@const item = $value[index]}
213
- {@const objectField = field.of.find((option) => option.slug === item.slug)}
214
-
215
- {#if objectField}
216
- <Accordion.Item value={index.toString()} class="border-0" data-depth={depth + 1}>
217
- <Accordion.Trigger
218
- class="items-center border px-4 text-base font-normal data-[state=open]:rounded-b-none dark:bg-slate-800/30 dark:hover:bg-slate-700/40 dark:border-white/[0.08]"
219
- >
220
- <div class="flex grow items-center justify-between gap-4">
221
- <div class="flex items-center gap-4">
222
- <span>{index < 10 ? '0' : ''}{index + 1}</span>
223
- <Badge variant="outline">{getLocalizedLabel(objectField.label, interfaceLanguage.current) || objectField.slug}</Badge>
224
- <span>{getAccordionLabel($value[index])}</span>
225
- </div>
226
-
227
- <DropdownMenu.Root>
228
- <DropdownMenu.Trigger
229
- class="data-[state=open]:bg-muted text-muted-foreground flex size-8"
230
- >
231
- {#snippet child({ props })}
232
- <Button variant="ghost" size="icon" {...props}>
233
- <DotsVerticalIcon />
234
- <span class="sr-only">Open menu</span>
235
- </Button>
236
- {/snippet}
237
- </DropdownMenu.Trigger>
238
- <DropdownMenu.Content align="end" class="w-32">
239
- <DropdownMenu.Item onclick={() => duplicateItem(index)}>
240
- Duplicate
241
- </DropdownMenu.Item>
242
- <DropdownMenu.Item onclick={() => moveItemUp(index)} disabled={index === 0}>
243
- Move up
244
- </DropdownMenu.Item>
245
- <DropdownMenu.Item
246
- onclick={() => moveItemDown(index)}
247
- disabled={$value && index === $value.length - 1}
226
+ <div
227
+ use:droppable={{
228
+ container: index.toString(),
229
+ callbacks: {
230
+ onDrop: (state) => {
231
+ if (dropProcessing) return;
232
+ dropProcessing = true;
233
+
234
+ const dragIndex = parseInt(state.sourceContainer ?? '');
235
+ const dropIndex = parseInt(state.targetContainer ?? '');
236
+ if (!isNaN(dragIndex) && !isNaN(dropIndex)) {
237
+ moveItem(dragIndex, dropIndex);
238
+ }
239
+
240
+ // {#key index} destroys the old draggable before dragend fires,
241
+ // leaving isDragging stuck at true. Reset manually.
242
+ dndState.isDragging = false;
243
+ dndState.draggedItem = null;
244
+ dndState.sourceContainer = '';
245
+ dndState.targetContainer = null;
246
+ dndState.targetElement = null;
247
+
248
+ setTimeout(() => { dropProcessing = false; }, 50);
249
+ }
250
+ }
251
+ }}
252
+ animate:flip={{ duration: 200 }}
253
+ >
254
+ {#key index}
255
+ {#if $value[index].data && $value[index].slug}
256
+ {@const item = $value[index]}
257
+ {@const objectField = field.of.find((option) => option.slug === item.slug)}
258
+
259
+ {#if objectField}
260
+ <Accordion.Item value={index.toString()} class="border-0" data-depth={depth + 1}>
261
+ <Accordion.Trigger
262
+ class="items-center border px-4 text-base font-normal data-[state=open]:rounded-b-none dark:bg-slate-800/30 dark:hover:bg-slate-700/40 dark:border-white/[0.08]"
263
+ >
264
+ <div class="flex grow items-center justify-between gap-4">
265
+ <div class="flex items-center gap-4">
266
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
267
+ <div
268
+ use:draggable={{ container: index.toString(), dragData: { id: item._id } }}
269
+ class="cursor-grab text-muted-foreground hover:text-foreground"
270
+ onmousedown={(e) => e.stopPropagation()}
271
+ onclick={(e) => e.stopPropagation()}
248
272
  >
249
- Move down
250
- </DropdownMenu.Item>
251
- <DropdownMenu.Item variant="destructive" onclick={() => removeItem(index)}>
252
- Delete
253
- </DropdownMenu.Item>
254
- </DropdownMenu.Content>
255
- </DropdownMenu.Root>
256
- </div>
257
- </Accordion.Trigger>
258
- <Accordion.Content
259
- class="space-y-4 rounded-b-md border border-t-0 dark:bg-slate-900/30 dark:shadow-[inset_0_2px_4px_rgb(0_0_0/0.1)] dark:border-white/[0.08]"
260
- style="padding: {Math.max(4, 16 - depth * 3)}px;"
261
- >
262
- {@const itemPath = joinPath(path, index)}
263
- <div data-field-path={itemPath}>
264
- <FieldRenderer
265
- objectFieldType="inline"
266
- field={objectField}
267
- form={form as SuperForm<Record<string, unknown>>}
268
- path={itemPath as FormPathLeaves<T, ObjectFieldData>}
269
- {focusedPath}
270
- {flashingPath}
271
- depth={depth + 1}
272
- />
273
- </div>
274
- </Accordion.Content>
275
- </Accordion.Item>
273
+ <GripVertical class="h-4 w-4" />
274
+ </div>
275
+ <span>{index < 10 ? '0' : ''}{index + 1}</span>
276
+ <Badge variant="outline">{getLocalizedLabel(objectField.label, interfaceLanguage.current) || objectField.slug}</Badge>
277
+ <span>{getAccordionLabel($value[index])}</span>
278
+ </div>
279
+
280
+ <DropdownMenu.Root>
281
+ <DropdownMenu.Trigger
282
+ class="data-[state=open]:bg-muted text-muted-foreground flex size-8"
283
+ >
284
+ {#snippet child({ props })}
285
+ <Button variant="ghost" size="icon" {...props}>
286
+ <DotsVerticalIcon />
287
+ <span class="sr-only">Open menu</span>
288
+ </Button>
289
+ {/snippet}
290
+ </DropdownMenu.Trigger>
291
+ <DropdownMenu.Content align="end" class="w-32">
292
+ <DropdownMenu.Item onclick={() => duplicateItem(index)}>
293
+ Duplicate
294
+ </DropdownMenu.Item>
295
+ <DropdownMenu.Item onclick={() => moveItemUp(index)} disabled={index === 0}>
296
+ Move up
297
+ </DropdownMenu.Item>
298
+ <DropdownMenu.Item
299
+ onclick={() => moveItemDown(index)}
300
+ disabled={$value && index === $value.length - 1}
301
+ >
302
+ Move down
303
+ </DropdownMenu.Item>
304
+ <DropdownMenu.Item variant="destructive" onclick={() => removeItem(index)}>
305
+ Delete
306
+ </DropdownMenu.Item>
307
+ </DropdownMenu.Content>
308
+ </DropdownMenu.Root>
309
+ </div>
310
+ </Accordion.Trigger>
311
+ <Accordion.Content
312
+ class="space-y-4 rounded-b-md border border-t-0 dark:bg-slate-900/30 dark:shadow-[inset_0_2px_4px_rgb(0_0_0/0.1)] dark:border-white/[0.08]"
313
+ style="padding: {Math.max(4, 16 - depth * 3)}px;"
314
+ >
315
+ {@const itemPath = joinPath(path, index)}
316
+ <div data-field-path={itemPath}>
317
+ <FieldRenderer
318
+ objectFieldType="inline"
319
+ field={objectField}
320
+ form={form as SuperForm<Record<string, unknown>>}
321
+ path={itemPath as FormPathLeaves<T, ObjectFieldData>}
322
+ {focusedPath}
323
+ {flashingPath}
324
+ depth={depth + 1}
325
+ />
326
+ </div>
327
+ </Accordion.Content>
328
+ </Accordion.Item>
329
+ {:else}
330
+ <p class="text-red-500">
331
+ Invalid field configuration. Unknown slug:
332
+ {$value[index].slug}
333
+ </p>
334
+ {/if}
276
335
  {:else}
277
- <p class="text-red-500">
278
- Invalid field configuration. Unknown slug:
279
- {$value[index].slug}
280
- </p>
336
+ <p class="text-red-500">Invalid field configuration. Index: {index}</p>
281
337
  {/if}
282
- {:else}
283
- <p class="text-red-500">Invalid field configuration. Index: {index}</p>
284
- {/if}
338
+ {/key}
339
+ </div>
285
340
  {/each}
286
341
  {/if}
287
342
  </Accordion.Root>
@@ -27,8 +27,8 @@
27
27
  import type { InterfaceLanguage } from '../../../types/languages.js';
28
28
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
29
29
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
30
- import { droppable } from '@thisux/sveltednd';
31
- import { draggable } from '@thisux/sveltednd';
30
+ import { droppable, draggable } from '@thisux/sveltednd';
31
+ import { arrayMove } from '../../utils/arrayMove.js';
32
32
  import { flip } from 'svelte/animate';
33
33
  import { fade } from 'svelte/transition';
34
34
 
@@ -195,15 +195,11 @@
195
195
  container: index.toString(),
196
196
  callbacks: {
197
197
  onDrop: (state) => {
198
- const { targetContainer, sourceContainer } = state;
199
- const dragIndex = parseInt(sourceContainer ?? '0');
200
- const dropIndex = parseInt(targetContainer ?? '0');
198
+ const dragIndex = parseInt(state.sourceContainer ?? '');
199
+ const dropIndex = parseInt(state.targetContainer ?? '');
201
200
 
202
- if (dragIndex !== -1 && !isNaN(dropIndex)) {
203
- const arr = getArrayValue();
204
- const [moved] = arr.splice(dragIndex, 1);
205
- arr.splice(dropIndex, 0, moved);
206
- $value = [...arr];
201
+ if (!isNaN(dragIndex) && !isNaN(dropIndex)) {
202
+ $value = [...arrayMove(getArrayValue(), dragIndex, dropIndex)];
207
203
  }
208
204
  }
209
205
  }