includio-cms 0.0.68 → 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.
- package/CHANGELOG.md +157 -0
- package/ROADMAP.md +73 -0
- package/dist/admin/components/fields/array-field.svelte +126 -71
- package/dist/admin/components/fields/relation-field.svelte +6 -10
- package/dist/admin/components/layout/nav-search.svelte +43 -31
- package/dist/admin/utils/arrayMove.d.ts +5 -0
- package/dist/admin/utils/arrayMove.js +12 -0
- package/dist/core/server/utils/sanitizeRichText.d.ts +1 -0
- package/dist/core/server/utils/sanitizeRichText.js +67 -0
- package/dist/updates/0.0.69/index.d.ts +2 -0
- package/dist/updates/0.0.69/index.js +12 -0
- package/dist/updates/index.js +2 -1
- 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)
|
|
@@ -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
|
-
|
|
212
|
-
{
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
</
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
</
|
|
254
|
-
</
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
{
|
|
283
|
-
|
|
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 {
|
|
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
|
|
199
|
-
const
|
|
200
|
-
const dropIndex = parseInt(targetContainer ?? '0');
|
|
198
|
+
const dragIndex = parseInt(state.sourceContainer ?? '');
|
|
199
|
+
const dropIndex = parseInt(state.targetContainer ?? '');
|
|
201
200
|
|
|
202
|
-
if (dragIndex
|
|
203
|
-
|
|
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
|
}
|
|
@@ -28,34 +28,49 @@
|
|
|
28
28
|
open = false;
|
|
29
29
|
goto(url);
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
async function getData() {
|
|
33
|
+
const [singles, collections, forms] = await Promise.all([
|
|
34
|
+
remotes.getSingles(),
|
|
35
|
+
remotes.getCollections(),
|
|
36
|
+
remotes.getForms()
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
return { singles, collections, forms };
|
|
40
|
+
}
|
|
31
41
|
</script>
|
|
32
42
|
|
|
33
43
|
<svelte:window {onkeydown} />
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
</
|
|
45
|
+
{#await getData() then { singles, collections, forms }}
|
|
46
|
+
<button
|
|
47
|
+
onclick={() => (open = true)}
|
|
48
|
+
class="text-muted-foreground mx-2 flex w-[calc(100%-1rem)] items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition-colors hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/10"
|
|
49
|
+
>
|
|
50
|
+
<SearchIcon class="size-4 shrink-0" />
|
|
51
|
+
<span class="flex-1 truncate text-left"
|
|
52
|
+
>{sidebarLang[interfaceLanguage.current].search.placeholder}</span
|
|
53
|
+
>
|
|
54
|
+
<kbd
|
|
55
|
+
class="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
|
56
|
+
>⌘K</kbd
|
|
57
|
+
>
|
|
58
|
+
</button>
|
|
43
59
|
|
|
44
|
-
<Command.Dialog bind:open title={sidebarLang[interfaceLanguage.current].search.placeholder}>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
<Command.Dialog bind:open title={sidebarLang[interfaceLanguage.current].search.placeholder}>
|
|
61
|
+
<Command.Input placeholder={sidebarLang[interfaceLanguage.current].search.placeholder} />
|
|
62
|
+
<Command.List>
|
|
63
|
+
<Command.Empty>{sidebarLang[interfaceLanguage.current].search.noResults}</Command.Empty>
|
|
48
64
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{#await remotes.getSingles() then singles}
|
|
65
|
+
<Command.Group heading={sidebarLang[interfaceLanguage.current].main.platform}>
|
|
66
|
+
<Command.Item onSelect={() => navigate('/admin')}>
|
|
67
|
+
<DashboardIcon class="mr-2 size-4" />
|
|
68
|
+
{sidebarLang[interfaceLanguage.current].main.dashboard}
|
|
69
|
+
</Command.Item>
|
|
70
|
+
<Command.Item onSelect={() => navigate('/admin/media')}>
|
|
71
|
+
<CameraIcon class="mr-2 size-4" />
|
|
72
|
+
{sidebarLang[interfaceLanguage.current].main.media}
|
|
73
|
+
</Command.Item>
|
|
59
74
|
{#each singles as item (item.slug)}
|
|
60
75
|
{@const name = getLocalizedLabel(item.label, interfaceLanguage.current) ?? item.slug}
|
|
61
76
|
<Command.Item onSelect={() => navigate(`/admin/entries/${item.slug}`)}>
|
|
@@ -67,14 +82,13 @@
|
|
|
67
82
|
{name}
|
|
68
83
|
</Command.Item>
|
|
69
84
|
{/each}
|
|
70
|
-
|
|
71
|
-
</Command.Group>
|
|
85
|
+
</Command.Group>
|
|
72
86
|
|
|
73
|
-
{#await remotes.getCollections() then collections}
|
|
74
87
|
{#if collections.length > 0}
|
|
75
88
|
<Command.Group heading={sidebarLang[interfaceLanguage.current].collections.title}>
|
|
76
89
|
{#each collections as item (item.slug)}
|
|
77
|
-
{@const name =
|
|
90
|
+
{@const name =
|
|
91
|
+
getLocalizedLabel(item.labels?.plural, interfaceLanguage.current) ?? item.slug}
|
|
78
92
|
<Command.Item onSelect={() => navigate(`/admin/collections/${item.slug}`)}>
|
|
79
93
|
{#if item.sidebarIcon}
|
|
80
94
|
<item.sidebarIcon class="mr-2 size-4" />
|
|
@@ -86,9 +100,7 @@
|
|
|
86
100
|
{/each}
|
|
87
101
|
</Command.Group>
|
|
88
102
|
{/if}
|
|
89
|
-
{/await}
|
|
90
103
|
|
|
91
|
-
{#await remotes.getForms() then forms}
|
|
92
104
|
{#if forms.length > 0}
|
|
93
105
|
<Command.Group heading={sidebarLang[interfaceLanguage.current].forms.title}>
|
|
94
106
|
{#each forms as item (item.slug)}
|
|
@@ -100,6 +112,6 @@
|
|
|
100
112
|
{/each}
|
|
101
113
|
</Command.Group>
|
|
102
114
|
{/if}
|
|
103
|
-
|
|
104
|
-
</Command.
|
|
105
|
-
|
|
115
|
+
</Command.List>
|
|
116
|
+
</Command.Dialog>
|
|
117
|
+
{/await}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move an element from one index to another via splice, returning a new array.
|
|
3
|
+
* Returns the original array reference (unchanged) when `from === to`.
|
|
4
|
+
*/
|
|
5
|
+
export function arrayMove(arr, from, to) {
|
|
6
|
+
if (from === to)
|
|
7
|
+
return arr;
|
|
8
|
+
const copy = [...arr];
|
|
9
|
+
const [moved] = copy.splice(from, 1);
|
|
10
|
+
copy.splice(to, 0, moved);
|
|
11
|
+
return copy;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sanitizeRichText(dirty: string): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
+
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
|
|
3
|
+
const tag = node.tagName.toLowerCase();
|
|
4
|
+
if (data.attrName === 'src' && !['img', 'video'].includes(tag)) {
|
|
5
|
+
data.keepAttr = false;
|
|
6
|
+
}
|
|
7
|
+
if (data.attrName === 'style') {
|
|
8
|
+
const value = node.getAttribute('style') || '';
|
|
9
|
+
const match = value.match(/text-align\s*:\s*(left|center|right|justify)/);
|
|
10
|
+
node.setAttribute('style', match ? `text-align: ${match[1]}` : '');
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
export function sanitizeRichText(dirty) {
|
|
14
|
+
return DOMPurify.sanitize(dirty, {
|
|
15
|
+
ALLOWED_TAGS: [
|
|
16
|
+
'p',
|
|
17
|
+
'h1',
|
|
18
|
+
'h2',
|
|
19
|
+
'h3',
|
|
20
|
+
'h4',
|
|
21
|
+
'h5',
|
|
22
|
+
'h6',
|
|
23
|
+
'strong',
|
|
24
|
+
'em',
|
|
25
|
+
's',
|
|
26
|
+
'code',
|
|
27
|
+
'ul',
|
|
28
|
+
'ol',
|
|
29
|
+
'li',
|
|
30
|
+
'blockquote',
|
|
31
|
+
'br',
|
|
32
|
+
'hr',
|
|
33
|
+
'a',
|
|
34
|
+
'img',
|
|
35
|
+
'table',
|
|
36
|
+
'thead',
|
|
37
|
+
'tbody',
|
|
38
|
+
'tr',
|
|
39
|
+
'th',
|
|
40
|
+
'td',
|
|
41
|
+
'mark',
|
|
42
|
+
'u',
|
|
43
|
+
'pre',
|
|
44
|
+
'video',
|
|
45
|
+
'figure',
|
|
46
|
+
'figcaption'
|
|
47
|
+
],
|
|
48
|
+
ALLOWED_ATTR: [
|
|
49
|
+
'href',
|
|
50
|
+
'target',
|
|
51
|
+
'rel',
|
|
52
|
+
'src',
|
|
53
|
+
'alt',
|
|
54
|
+
'title',
|
|
55
|
+
'class',
|
|
56
|
+
'colspan',
|
|
57
|
+
'rowspan',
|
|
58
|
+
'colwidth',
|
|
59
|
+
'style',
|
|
60
|
+
'poster',
|
|
61
|
+
'data-media-id',
|
|
62
|
+
'controls',
|
|
63
|
+
'width',
|
|
64
|
+
'height'
|
|
65
|
+
]
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.0.69',
|
|
3
|
+
date: '2026-02-17',
|
|
4
|
+
description: 'DnD array reordering, nav-search batch fetch',
|
|
5
|
+
features: [
|
|
6
|
+
'Drag-and-drop reordering in array fields with arrayMove utility'
|
|
7
|
+
],
|
|
8
|
+
fixes: [
|
|
9
|
+
'Nav-search: batch-fetch data before rendering Command dialog — fixes missing data on open'
|
|
10
|
+
],
|
|
11
|
+
breakingChanges: []
|
|
12
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -2,7 +2,8 @@ import { update as update0065 } from './0.0.65/index.js';
|
|
|
2
2
|
import { update as update0066 } from './0.0.66/index.js';
|
|
3
3
|
import { update as update0067 } from './0.0.67/index.js';
|
|
4
4
|
import { update as update0068 } from './0.0.68/index.js';
|
|
5
|
-
|
|
5
|
+
import { update as update0069 } from './0.0.69/index.js';
|
|
6
|
+
export const updates = [update0065, update0066, update0067, update0068, update0069];
|
|
6
7
|
export const getUpdatesFrom = (fromVersion) => {
|
|
7
8
|
const fromParts = fromVersion.split('.').map(Number);
|
|
8
9
|
return updates.filter((update) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "includio-cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.69",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && npm run prepack",
|
|
@@ -22,13 +22,17 @@
|
|
|
22
22
|
"build-storybook": "storybook build",
|
|
23
23
|
"create:user": "tsx src/lib/core/server/auth/scripts/createUser.ts",
|
|
24
24
|
"cli": "tsx cli/init/index.ts",
|
|
25
|
-
"build:cli": "tsc cli/init/index.ts --outDir dist/cli/init --module commonjs --esModuleInterop"
|
|
25
|
+
"build:cli": "tsc cli/init/index.ts --outDir dist/cli/init --module commonjs --esModuleInterop",
|
|
26
|
+
"changelog": "tsx scripts/generate-changelog.ts",
|
|
27
|
+
"prepublishOnly": "tsx scripts/generate-changelog.ts"
|
|
26
28
|
},
|
|
27
29
|
"files": [
|
|
28
30
|
"dist",
|
|
29
31
|
"!dist/**/*.test.*",
|
|
30
32
|
"!dist/**/*.spec.*",
|
|
31
33
|
"README.md",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"ROADMAP.md",
|
|
32
36
|
"LICENSE"
|
|
33
37
|
],
|
|
34
38
|
"sideEffects": [
|
|
@@ -196,6 +200,7 @@
|
|
|
196
200
|
"dotenv": "^16.5.0",
|
|
197
201
|
"fast-glob": "^3.3.3",
|
|
198
202
|
"fluent-ffmpeg": "^2.1.3",
|
|
203
|
+
"isomorphic-dompurify": "3.0.0-rc.2",
|
|
199
204
|
"lowlight": "^3.3.0",
|
|
200
205
|
"mode-watcher": "^1.0.8",
|
|
201
206
|
"nodemailer": "^7.0.11",
|