includio-cms 0.5.7 → 0.6.1
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 +49 -0
- package/ROADMAP.md +20 -4
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-gc.d.ts +3 -0
- package/dist/admin/api/media-gc.js +27 -0
- package/dist/admin/client/collection/collection-entries.svelte +43 -1
- package/dist/admin/client/collection/table-toolbar.svelte +64 -1
- package/dist/admin/client/collection/table-toolbar.svelte.d.ts +11 -0
- package/dist/admin/client/index.d.ts +1 -0
- package/dist/admin/client/index.js +1 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +205 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte.d.ts +3 -0
- package/dist/admin/components/fields/field-renderer.svelte +3 -2
- package/dist/admin/components/fields/field-renderer.svelte.d.ts +1 -0
- package/dist/admin/components/fields/object-field.svelte +5 -5
- package/dist/admin/components/fields/object-field.svelte.d.ts +1 -1
- package/dist/admin/components/fields/text-field-wrapper.svelte +5 -3
- package/dist/admin/components/layout/lang.d.ts +1 -0
- package/dist/admin/components/layout/lang.js +4 -2
- package/dist/admin/components/layout/layout-renderer.svelte +81 -107
- package/dist/admin/components/layout/layout-renderer.svelte.d.ts +1 -0
- package/dist/admin/components/layout/nav-main.svelte +6 -0
- package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +13 -6
- package/dist/admin/components/tiptap/content-editor.svelte +11 -2
- package/dist/admin/styles/admin.css +2 -1
- package/dist/ai-claude/index.js +10 -4
- package/dist/cli/index.js +10 -3
- package/dist/cli/install-peers.d.ts +3 -0
- package/dist/cli/install-peers.js +52 -0
- package/dist/core/fields/fieldSchemaToTs.js +2 -0
- package/dist/core/fields/layoutUtils.d.ts +30 -3
- package/dist/core/fields/layoutUtils.js +145 -17
- package/dist/core/server/generator/generator.js +21 -10
- package/dist/core/server/media/operations/purgeImageStyles.d.ts +7 -0
- package/dist/core/server/media/operations/purgeImageStyles.js +25 -0
- package/dist/core/server/media/operations/reconcileMedia.d.ts +12 -0
- package/dist/core/server/media/operations/reconcileMedia.js +62 -0
- package/dist/core/server/media/styles/operations/createMediaStyle.js +12 -1
- package/dist/db-postgres/index.js +25 -12
- package/dist/db-postgres/schema/imageStyle.js +2 -0
- package/dist/entity/index.d.ts +26 -0
- package/dist/entity/index.js +113 -0
- package/dist/files-local/index.js +11 -1
- package/dist/types/adapters/db.d.ts +8 -0
- package/dist/types/adapters/files.d.ts +2 -0
- package/dist/types/layout.d.ts +8 -0
- package/dist/updates/0.5.8/index.d.ts +2 -0
- package/dist/updates/0.5.8/index.js +27 -0
- package/dist/updates/0.6.0/index.d.ts +2 -0
- package/dist/updates/0.6.0/index.js +20 -0
- package/dist/updates/index.js +3 -1
- package/package.json +48 -14
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,55 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.6.0 — 2026-03-10
|
|
7
|
+
|
|
8
|
+
Entity module, collection filters, layout dot-notation, peerDeps migration
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Entity module — programmatic CRUD API for entries (`getEntity`)
|
|
12
|
+
- CLI `install-peers` command for automatic peer dependency installation
|
|
13
|
+
- Collection data filters — filter entries by select/radio field values in toolbar
|
|
14
|
+
- Layout dot-notation — distribute object fields across layout nodes
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- AI Claude: lazy client init, no crash without API key
|
|
18
|
+
- Codegen: quote hyphenated slugs, PascalCase form schemas, improved query types
|
|
19
|
+
- TipTap: inline block content field lang fix + placeholder UX
|
|
20
|
+
- Zod schema: skip lang wrapper for non-localized content field
|
|
21
|
+
|
|
22
|
+
### Breaking
|
|
23
|
+
- Runtime dependencies moved to peerDependencies — run `pnpm includio install-peers` after upgrade
|
|
24
|
+
|
|
25
|
+
## 0.5.8 — 2026-03-09
|
|
26
|
+
|
|
27
|
+
Media GC: deduplicate image styles, auto-cleanup, admin maintenance page
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Image style upsert: replace duplicates instead of creating new files
|
|
31
|
+
- Auto-cleanup old style files from disk on regeneration
|
|
32
|
+
- Admin maintenance page: purge styles, reconcile orphaned disk files
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Deduplicate existing image style records (keep newest per unique key)
|
|
36
|
+
|
|
37
|
+
### Migration
|
|
38
|
+
|
|
39
|
+
```sql
|
|
40
|
+
-- Deduplicate: keep newest style (by id) per unique key, delete rest
|
|
41
|
+
DELETE FROM image_styles a
|
|
42
|
+
USING image_styles b
|
|
43
|
+
WHERE a.media_file_id = b.media_file_id
|
|
44
|
+
AND a.name = b.name
|
|
45
|
+
AND COALESCE(a.width, 0) = COALESCE(b.width, 0)
|
|
46
|
+
AND COALESCE(a.height, 0) = COALESCE(b.height, 0)
|
|
47
|
+
AND COALESCE(a.quality, 0) = COALESCE(b.quality, 0)
|
|
48
|
+
AND a.id < b.id;
|
|
49
|
+
|
|
50
|
+
-- Unique index to prevent future duplicates
|
|
51
|
+
CREATE UNIQUE INDEX IF NOT EXISTS image_styles_unique_key
|
|
52
|
+
ON image_styles (media_file_id, name, COALESCE(width, 0), COALESCE(height, 0), COALESCE(quality, 0));
|
|
53
|
+
```
|
|
54
|
+
|
|
6
55
|
## 0.5.7 — 2026-03-06
|
|
7
56
|
|
|
8
57
|
SEO field with slug toggle, structured-content & URL field fixes
|
package/ROADMAP.md
CHANGED
|
@@ -122,11 +122,24 @@
|
|
|
122
122
|
- [x] `[fix]` `[P1]` resolveUrlFields: normalize flat string url/text, use raw DB calls <!-- files: src/lib/core/server/entries/ -->
|
|
123
123
|
- [x] `[chore]` `[P1]` Migrate #await to reactive query pattern across admin UI
|
|
124
124
|
|
|
125
|
-
## 0.
|
|
125
|
+
## 0.5.8 — Media GC
|
|
126
126
|
|
|
127
|
-
- [
|
|
128
|
-
- [
|
|
129
|
-
- [
|
|
127
|
+
- [x] `[feature]` `[P1]` Image style upsert: replace duplicates instead of creating new files
|
|
128
|
+
- [x] `[feature]` `[P1]` Auto-cleanup old style files from disk on regeneration
|
|
129
|
+
- [x] `[feature]` `[P1]` Admin maintenance page: purge styles, reconcile orphaned disk files
|
|
130
|
+
- [x] `[fix]` `[P1]` Deduplicate existing image style records (keep newest per unique key)
|
|
131
|
+
|
|
132
|
+
## 0.6.0 — Entity module & DX
|
|
133
|
+
|
|
134
|
+
- [x] `[feature]` `[P0]` Entity module — programmatic CRUD API for entries (`getEntity`) <!-- files: src/lib/entity/ -->
|
|
135
|
+
- [x] `[feature]` `[P1]` CLI `install-peers` command <!-- files: src/lib/cli/ -->
|
|
136
|
+
- [x] `[feature]` `[P1]` Collection data filters — filter by select/radio fields in toolbar <!-- files: src/lib/admin/client/collection/ -->
|
|
137
|
+
- [x] `[feature]` `[P1]` Layout dot-notation — distribute object fields across layout nodes <!-- files: src/lib/admin/components/fields/ -->
|
|
138
|
+
- [x] `[fix]` `[P1]` AI Claude: lazy client init, no crash without API key <!-- files: src/lib/ai-claude/ -->
|
|
139
|
+
- [x] `[fix]` `[P1]` Codegen: quote hyphenated slugs, PascalCase form schemas, improved query types <!-- files: src/lib/core/server/generator/ -->
|
|
140
|
+
- [x] `[fix]` `[P1]` TipTap: inline block content field lang fix + placeholder UX
|
|
141
|
+
- [x] `[fix]` `[P1]` Zod schema: skip lang wrapper for non-localized content field
|
|
142
|
+
- [x] `[breaking]` `[P0]` Runtime deps → peerDependencies (run `pnpm includio install-peers`)
|
|
130
143
|
|
|
131
144
|
## 0.6.1 — Admin experience
|
|
132
145
|
|
|
@@ -164,3 +177,6 @@
|
|
|
164
177
|
- [ ] `[feature]` `[P1]` Impersonation UI — impersonate/stop bar <!-- files: src/lib/admin/client/users/impersonation-bar.svelte -->
|
|
165
178
|
- [ ] `[chore]` `[P2]` Caching/performance layer (scope TBD)
|
|
166
179
|
- [ ] `[feature]` `[P2]` API/CLI for configuration (setup DX for less technical users)
|
|
180
|
+
- [ ] `[feature]` `[P0]` Plugin hooks in CRUD ops (before/afterCreate, Update, Delete) <!-- files: src/lib/types/plugins.ts, src/lib/core/server/entries/operations/ -->
|
|
181
|
+
- [ ] `[feature]` `[P0]` Plugin registration API — public surface for external plugins
|
|
182
|
+
- [ ] `[chore]` `[P1]` Plugin system documentation
|
|
@@ -4,6 +4,7 @@ import * as orphanedHandlers from './orphaned.js';
|
|
|
4
4
|
import * as replaceHandlers from './replace.js';
|
|
5
5
|
import * as inviteHandlers from './invite.js';
|
|
6
6
|
import * as acceptInviteHandlers from './accept-invite.js';
|
|
7
|
+
import * as mediaGcHandlers from './media-gc.js';
|
|
7
8
|
export function createAdminApiHandler(options) {
|
|
8
9
|
const routes = {
|
|
9
10
|
upload: uploadHandlers,
|
|
@@ -11,6 +12,7 @@ export function createAdminApiHandler(options) {
|
|
|
11
12
|
replace: replaceHandlers,
|
|
12
13
|
invite: inviteHandlers,
|
|
13
14
|
'accept-invite': acceptInviteHandlers,
|
|
15
|
+
'media-gc': mediaGcHandlers,
|
|
14
16
|
...options?.extraRoutes
|
|
15
17
|
};
|
|
16
18
|
function handle(method) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { requireRole } from '../remote/middleware/auth.js';
|
|
2
|
+
import { getImageStylesStats, purgeAllImageStyles } from '../../core/server/media/operations/purgeImageStyles.js';
|
|
3
|
+
import { getReconciliationReport, deleteOrphanedDiskFiles } from '../../core/server/media/operations/reconcileMedia.js';
|
|
4
|
+
import { json } from '@sveltejs/kit';
|
|
5
|
+
export const GET = async ({ url }) => {
|
|
6
|
+
requireRole('admin');
|
|
7
|
+
const stats = await getImageStylesStats();
|
|
8
|
+
const report = await getReconciliationReport();
|
|
9
|
+
return json({
|
|
10
|
+
imageStylesCount: stats.count,
|
|
11
|
+
orphanedDiskFiles: report.orphanedDisk,
|
|
12
|
+
missingDiskRecords: report.missingDisk
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
export const DELETE = async ({ url }) => {
|
|
16
|
+
requireRole('admin');
|
|
17
|
+
const action = url.searchParams.get('action');
|
|
18
|
+
if (action === 'purge-styles') {
|
|
19
|
+
const result = await purgeAllImageStyles();
|
|
20
|
+
return json(result);
|
|
21
|
+
}
|
|
22
|
+
if (action === 'delete-orphaned-disk') {
|
|
23
|
+
const result = await deleteOrphanedDiskFiles();
|
|
24
|
+
return json(result);
|
|
25
|
+
}
|
|
26
|
+
return json({ error: 'Unknown action' }, { status: 400 });
|
|
27
|
+
};
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import EmptyState from './empty-state.svelte';
|
|
29
29
|
import { createCollectionViewState } from './collection-view.svelte.js';
|
|
30
30
|
import type { EntryStatus as EntryStatusType, RawEntry } from '../../../types/entries.js';
|
|
31
|
-
import type { RelationField } from '../../../types/fields.js';
|
|
31
|
+
import type { RelationField, SelectField, RadioField } from '../../../types/fields.js';
|
|
32
32
|
import {
|
|
33
33
|
validateA11y,
|
|
34
34
|
a11yLangPl,
|
|
@@ -144,6 +144,31 @@
|
|
|
144
144
|
);
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
// Data filter configs from select/radio fields in listColumns
|
|
148
|
+
const dataFilterConfigs = $derived(
|
|
149
|
+
(collection.listColumns ?? [])
|
|
150
|
+
.map((slug) => {
|
|
151
|
+
const field = collection.fields.find((f) => f.slug === slug);
|
|
152
|
+
if (field?.type === 'select' || field?.type === 'radio') {
|
|
153
|
+
const f = field as SelectField | RadioField;
|
|
154
|
+
return {
|
|
155
|
+
slug: f.slug,
|
|
156
|
+
label: getLocalizedLabel(f.label, interfaceLanguage.current),
|
|
157
|
+
options: f.options.map((o) => ({
|
|
158
|
+
label: getLocalizedLabel(o.label, interfaceLanguage.current),
|
|
159
|
+
value: o.value
|
|
160
|
+
}))
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
})
|
|
165
|
+
.filter((f): f is NonNullable<typeof f> => f !== null)
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
let activeDataFilters = $state<Record<string, string | null>>({});
|
|
169
|
+
|
|
170
|
+
const hasActiveDataFilter = $derived(Object.values(activeDataFilters).some((v) => v !== null));
|
|
171
|
+
|
|
147
172
|
// Find seo field for slug extraction
|
|
148
173
|
const seoField = $derived(collection.fields.find((f) => f.type === 'seo'));
|
|
149
174
|
|
|
@@ -588,6 +613,17 @@
|
|
|
588
613
|
if (!isArchivedFilter) {
|
|
589
614
|
rows = filterByStatus(rows);
|
|
590
615
|
}
|
|
616
|
+
// Client-side data filter (select/radio fields)
|
|
617
|
+
if (hasActiveDataFilter) {
|
|
618
|
+
rows = rows.filter((row) => {
|
|
619
|
+
for (const [slug, value] of Object.entries(activeDataFilters)) {
|
|
620
|
+
if (value === null) continue;
|
|
621
|
+
const rowValue = row.customData[slug];
|
|
622
|
+
if (String(rowValue ?? '') !== value) return false;
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
591
627
|
// Client-side search
|
|
592
628
|
if (searchQuery) {
|
|
593
629
|
rows = rows.filter((item) => item.searchText.includes(searchQuery.toLowerCase()));
|
|
@@ -623,6 +659,12 @@
|
|
|
623
659
|
onViewModeChange={(m) => (viewState.viewMode = m)}
|
|
624
660
|
{onCreateEntry}
|
|
625
661
|
createLabel={addLabel}
|
|
662
|
+
dataFilters={dataFilterConfigs}
|
|
663
|
+
{activeDataFilters}
|
|
664
|
+
onDataFilterChange={(slug, value) => {
|
|
665
|
+
activeDataFilters = { ...activeDataFilters, [slug]: value };
|
|
666
|
+
viewState.pageIndex = 0;
|
|
667
|
+
}}
|
|
626
668
|
/>
|
|
627
669
|
|
|
628
670
|
{#if totalItems === 0 && !searchQuery}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
import type { InterfaceLanguage } from '../../../types/languages.js';
|
|
12
12
|
import type { ViewMode, StatusFilter } from './collection-view.svelte.js';
|
|
13
13
|
|
|
14
|
+
interface DataFilterConfig {
|
|
15
|
+
slug: string;
|
|
16
|
+
label: string;
|
|
17
|
+
options: { label: string; value: string }[];
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
type Props = {
|
|
15
21
|
searchQuery: string;
|
|
16
22
|
onSearchChange: (query: string) => void;
|
|
@@ -21,6 +27,9 @@
|
|
|
21
27
|
onCreateEntry: () => void;
|
|
22
28
|
createLabel: string;
|
|
23
29
|
searchPlaceholder?: string;
|
|
30
|
+
dataFilters?: DataFilterConfig[];
|
|
31
|
+
activeDataFilters?: Record<string, string | null>;
|
|
32
|
+
onDataFilterChange?: (slug: string, value: string | null) => void;
|
|
24
33
|
};
|
|
25
34
|
|
|
26
35
|
let {
|
|
@@ -32,7 +41,10 @@
|
|
|
32
41
|
onViewModeChange,
|
|
33
42
|
onCreateEntry,
|
|
34
43
|
createLabel,
|
|
35
|
-
searchPlaceholder
|
|
44
|
+
searchPlaceholder,
|
|
45
|
+
dataFilters = [],
|
|
46
|
+
activeDataFilters = {},
|
|
47
|
+
onDataFilterChange
|
|
36
48
|
}: Props = $props();
|
|
37
49
|
|
|
38
50
|
const interfaceLanguage = useInterfaceLanguage();
|
|
@@ -92,6 +104,7 @@
|
|
|
92
104
|
const hasActiveFilter = $derived(statusFilter !== null);
|
|
93
105
|
|
|
94
106
|
let statusPopoverOpen = $state(false);
|
|
107
|
+
let dataFilterPopovers = $state<Record<string, boolean>>({});
|
|
95
108
|
</script>
|
|
96
109
|
|
|
97
110
|
<div class="mb-6 flex flex-wrap items-center gap-2.5">
|
|
@@ -142,6 +155,56 @@
|
|
|
142
155
|
</Popover.Content>
|
|
143
156
|
</Popover.Root>
|
|
144
157
|
|
|
158
|
+
{#each dataFilters as filter}
|
|
159
|
+
{@const activeValue = activeDataFilters[filter.slug] ?? null}
|
|
160
|
+
{@const hasActive = activeValue !== null}
|
|
161
|
+
{@const activeLabel = filter.options.find((o) => o.value === activeValue)?.label ?? ''}
|
|
162
|
+
<Popover.Root open={dataFilterPopovers[filter.slug] ?? false} onOpenChange={(v) => dataFilterPopovers[filter.slug] = v}>
|
|
163
|
+
<Popover.Trigger>
|
|
164
|
+
{#snippet child({ props })}
|
|
165
|
+
<Button
|
|
166
|
+
{...props}
|
|
167
|
+
variant="outline"
|
|
168
|
+
size="sm"
|
|
169
|
+
class="gap-1.5 {hasActive
|
|
170
|
+
? 'border-primary/30 bg-lavender-lighter text-primary'
|
|
171
|
+
: ''}"
|
|
172
|
+
>
|
|
173
|
+
<Filter class="size-3.5" />
|
|
174
|
+
{filter.label}{hasActive ? `: ${activeLabel}` : ''}
|
|
175
|
+
</Button>
|
|
176
|
+
{/snippet}
|
|
177
|
+
</Popover.Trigger>
|
|
178
|
+
<Popover.Content class="w-44 p-1" align="start">
|
|
179
|
+
<button
|
|
180
|
+
class="hover:bg-accent flex w-full items-center rounded-md px-2.5 py-1.5 text-sm transition-colors {!hasActive
|
|
181
|
+
? 'bg-accent text-accent-foreground font-medium'
|
|
182
|
+
: 'text-foreground'}"
|
|
183
|
+
onclick={() => {
|
|
184
|
+
onDataFilterChange?.(filter.slug, null);
|
|
185
|
+
dataFilterPopovers[filter.slug] = false;
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{t.all}
|
|
189
|
+
</button>
|
|
190
|
+
{#each filter.options as option}
|
|
191
|
+
<button
|
|
192
|
+
class="hover:bg-accent flex w-full items-center rounded-md px-2.5 py-1.5 text-sm transition-colors {activeValue ===
|
|
193
|
+
option.value
|
|
194
|
+
? 'bg-accent text-accent-foreground font-medium'
|
|
195
|
+
: 'text-foreground'}"
|
|
196
|
+
onclick={() => {
|
|
197
|
+
onDataFilterChange?.(filter.slug, option.value);
|
|
198
|
+
dataFilterPopovers[filter.slug] = false;
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{option.label}
|
|
202
|
+
</button>
|
|
203
|
+
{/each}
|
|
204
|
+
</Popover.Content>
|
|
205
|
+
</Popover.Root>
|
|
206
|
+
{/each}
|
|
207
|
+
|
|
145
208
|
<div class="flex items-center gap-0.5 rounded-lg border p-0.5">
|
|
146
209
|
<Button
|
|
147
210
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import type { ViewMode, StatusFilter } from './collection-view.svelte.js';
|
|
2
|
+
interface DataFilterConfig {
|
|
3
|
+
slug: string;
|
|
4
|
+
label: string;
|
|
5
|
+
options: {
|
|
6
|
+
label: string;
|
|
7
|
+
value: string;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
2
10
|
type Props = {
|
|
3
11
|
searchQuery: string;
|
|
4
12
|
onSearchChange: (query: string) => void;
|
|
@@ -9,6 +17,9 @@ type Props = {
|
|
|
9
17
|
onCreateEntry: () => void;
|
|
10
18
|
createLabel: string;
|
|
11
19
|
searchPlaceholder?: string;
|
|
20
|
+
dataFilters?: DataFilterConfig[];
|
|
21
|
+
activeDataFilters?: Record<string, string | null>;
|
|
22
|
+
onDataFilterChange?: (slug: string, value: string | null) => void;
|
|
12
23
|
};
|
|
13
24
|
declare const TableToolbar: import("svelte").Component<Props, {}, "">;
|
|
14
25
|
type TableToolbar = ReturnType<typeof TableToolbar>;
|
|
@@ -9,3 +9,4 @@ export { default as FormPage } from './form/form-page.svelte';
|
|
|
9
9
|
export { default as FormSubmissionPage } from './form/form-submission/form-submission-page.svelte';
|
|
10
10
|
export { default as UsersPage } from './users/users-page.svelte';
|
|
11
11
|
export { default as AcceptInvitePage } from './users/accept-invite-page.svelte';
|
|
12
|
+
export { default as MaintenancePage } from './maintenance/maintenance-page.svelte';
|
|
@@ -9,3 +9,4 @@ export { default as FormPage } from './form/form-page.svelte';
|
|
|
9
9
|
export { default as FormSubmissionPage } from './form/form-submission/form-submission-page.svelte';
|
|
10
10
|
export { default as UsersPage } from './users/users-page.svelte';
|
|
11
11
|
export { default as AcceptInvitePage } from './users/accept-invite-page.svelte';
|
|
12
|
+
export { default as MaintenancePage } from './maintenance/maintenance-page.svelte';
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Loader2 from '@tabler/icons-svelte/icons/loader-2';
|
|
3
|
+
import Trash from '@tabler/icons-svelte/icons/trash';
|
|
4
|
+
import Refresh from '@tabler/icons-svelte/icons/refresh';
|
|
5
|
+
import FileSearch from '@tabler/icons-svelte/icons/file-search';
|
|
6
|
+
import Photo from '@tabler/icons-svelte/icons/photo';
|
|
7
|
+
import AlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';
|
|
8
|
+
import Button from '../../../components/ui/button/button.svelte';
|
|
9
|
+
import * as Card from '../../../components/ui/card/index.js';
|
|
10
|
+
import { toast } from 'svelte-sonner';
|
|
11
|
+
|
|
12
|
+
interface GcReport {
|
|
13
|
+
imageStylesCount: number;
|
|
14
|
+
orphanedDiskFiles: string[];
|
|
15
|
+
missingDiskRecords: { table: string; id: string; url: string }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let report = $state<GcReport | null>(null);
|
|
19
|
+
let loading = $state(true);
|
|
20
|
+
let purging = $state(false);
|
|
21
|
+
let cleaningOrphans = $state(false);
|
|
22
|
+
|
|
23
|
+
async function loadReport() {
|
|
24
|
+
loading = true;
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch('/admin/api/media-gc');
|
|
27
|
+
if (!res.ok) throw new Error('Failed to load');
|
|
28
|
+
report = await res.json();
|
|
29
|
+
} catch {
|
|
30
|
+
toast.error('Nie udało się załadować raportu');
|
|
31
|
+
} finally {
|
|
32
|
+
loading = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function purgeStyles() {
|
|
37
|
+
purging = true;
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch('/admin/api/media-gc?action=purge-styles', { method: 'DELETE' });
|
|
40
|
+
if (!res.ok) throw new Error('Failed');
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
toast.success(`Usunięto ${data.deletedCount} styli (${data.filesDeleted} plików z dysku)`);
|
|
43
|
+
await loadReport();
|
|
44
|
+
} catch {
|
|
45
|
+
toast.error('Nie udało się usunąć styli');
|
|
46
|
+
} finally {
|
|
47
|
+
purging = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function cleanOrphans() {
|
|
52
|
+
cleaningOrphans = true;
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch('/admin/api/media-gc?action=delete-orphaned-disk', { method: 'DELETE' });
|
|
55
|
+
if (!res.ok) throw new Error('Failed');
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
toast.success(`Usunięto ${data.deletedCount} osieroconych plików`);
|
|
58
|
+
await loadReport();
|
|
59
|
+
} catch {
|
|
60
|
+
toast.error('Nie udało się wyczyścić plików');
|
|
61
|
+
} finally {
|
|
62
|
+
cleaningOrphans = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$effect(() => {
|
|
67
|
+
loadReport();
|
|
68
|
+
});
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<div class="p-5 pb-24 md:p-7">
|
|
72
|
+
<div class="mb-6">
|
|
73
|
+
<h1 class="mb-1 text-2xl font-bold">Konserwacja</h1>
|
|
74
|
+
<p class="text-sm" style="color: var(--muted-foreground);">
|
|
75
|
+
Narzędzia do zarządzania plikami mediów i stylami obrazów
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{#if loading}
|
|
80
|
+
<div class="flex items-center justify-center py-20">
|
|
81
|
+
<Loader2 class="size-6 animate-spin" style="color: var(--muted-foreground);" />
|
|
82
|
+
</div>
|
|
83
|
+
{:else if report}
|
|
84
|
+
<div class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
|
85
|
+
<!-- Image styles -->
|
|
86
|
+
<Card.Root>
|
|
87
|
+
<Card.Header>
|
|
88
|
+
<div class="flex items-center gap-2">
|
|
89
|
+
<Photo class="size-5" style="color: var(--primary);" />
|
|
90
|
+
<Card.Title>Style obrazów</Card.Title>
|
|
91
|
+
</div>
|
|
92
|
+
<Card.Description>
|
|
93
|
+
Wygenerowane warianty (mniejsze rozmiary, WebP/AVIF)
|
|
94
|
+
</Card.Description>
|
|
95
|
+
</Card.Header>
|
|
96
|
+
<Card.Content>
|
|
97
|
+
<p class="mb-4 text-3xl font-bold" style="color: var(--primary);">
|
|
98
|
+
{report.imageStylesCount}
|
|
99
|
+
</p>
|
|
100
|
+
<Button
|
|
101
|
+
variant="destructive"
|
|
102
|
+
size="sm"
|
|
103
|
+
onclick={purgeStyles}
|
|
104
|
+
disabled={purging || report.imageStylesCount === 0}
|
|
105
|
+
>
|
|
106
|
+
{#if purging}
|
|
107
|
+
<Loader2 class="size-4 animate-spin" />
|
|
108
|
+
{:else}
|
|
109
|
+
<Trash class="size-4" />
|
|
110
|
+
{/if}
|
|
111
|
+
Usuń wszystkie i regeneruj
|
|
112
|
+
</Button>
|
|
113
|
+
<p class="mt-2 text-xs" style="color: var(--text-light);">
|
|
114
|
+
Style zostaną odtworzone automatycznie przy kolejnym wyświetleniu
|
|
115
|
+
</p>
|
|
116
|
+
</Card.Content>
|
|
117
|
+
</Card.Root>
|
|
118
|
+
|
|
119
|
+
<!-- Orphaned disk files -->
|
|
120
|
+
<Card.Root>
|
|
121
|
+
<Card.Header>
|
|
122
|
+
<div class="flex items-center gap-2">
|
|
123
|
+
<FileSearch class="size-5" style="color: var(--warning, #C4893A);" />
|
|
124
|
+
<Card.Title>Osierocone pliki</Card.Title>
|
|
125
|
+
</div>
|
|
126
|
+
<Card.Description>
|
|
127
|
+
Pliki na dysku bez odpowiadających rekordów w bazie danych
|
|
128
|
+
</Card.Description>
|
|
129
|
+
</Card.Header>
|
|
130
|
+
<Card.Content>
|
|
131
|
+
<p class="mb-4 text-3xl font-bold" style="color: {report.orphanedDiskFiles.length > 0 ? 'var(--warning, #C4893A)' : 'var(--success, #3A8A5C)'};">
|
|
132
|
+
{report.orphanedDiskFiles.length}
|
|
133
|
+
</p>
|
|
134
|
+
{#if report.orphanedDiskFiles.length > 0}
|
|
135
|
+
<Button
|
|
136
|
+
variant="outline"
|
|
137
|
+
size="sm"
|
|
138
|
+
onclick={cleanOrphans}
|
|
139
|
+
disabled={cleaningOrphans}
|
|
140
|
+
>
|
|
141
|
+
{#if cleaningOrphans}
|
|
142
|
+
<Loader2 class="size-4 animate-spin" />
|
|
143
|
+
{:else}
|
|
144
|
+
<Trash class="size-4" />
|
|
145
|
+
{/if}
|
|
146
|
+
Usuń osierocone pliki
|
|
147
|
+
</Button>
|
|
148
|
+
<details class="mt-3">
|
|
149
|
+
<summary class="cursor-pointer text-xs" style="color: var(--text-light);">
|
|
150
|
+
Pokaż pliki ({report.orphanedDiskFiles.length})
|
|
151
|
+
</summary>
|
|
152
|
+
<ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
|
|
153
|
+
{#each report.orphanedDiskFiles as file}
|
|
154
|
+
<li class="truncate py-0.5">{file}</li>
|
|
155
|
+
{/each}
|
|
156
|
+
</ul>
|
|
157
|
+
</details>
|
|
158
|
+
{:else}
|
|
159
|
+
<p class="text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
|
|
160
|
+
{/if}
|
|
161
|
+
</Card.Content>
|
|
162
|
+
</Card.Root>
|
|
163
|
+
|
|
164
|
+
<!-- Missing disk files -->
|
|
165
|
+
<Card.Root>
|
|
166
|
+
<Card.Header>
|
|
167
|
+
<div class="flex items-center gap-2">
|
|
168
|
+
<AlertTriangle class="size-5" style="color: var(--error, #C44B4B);" />
|
|
169
|
+
<Card.Title>Brakujące pliki</Card.Title>
|
|
170
|
+
</div>
|
|
171
|
+
<Card.Description>
|
|
172
|
+
Rekordy w bazie danych bez plików na dysku
|
|
173
|
+
</Card.Description>
|
|
174
|
+
</Card.Header>
|
|
175
|
+
<Card.Content>
|
|
176
|
+
<p class="mb-4 text-3xl font-bold" style="color: {report.missingDiskRecords.length > 0 ? 'var(--error, #C44B4B)' : 'var(--success, #3A8A5C)'};">
|
|
177
|
+
{report.missingDiskRecords.length}
|
|
178
|
+
</p>
|
|
179
|
+
{#if report.missingDiskRecords.length > 0}
|
|
180
|
+
<details>
|
|
181
|
+
<summary class="cursor-pointer text-xs" style="color: var(--text-light);">
|
|
182
|
+
Pokaż rekordy ({report.missingDiskRecords.length})
|
|
183
|
+
</summary>
|
|
184
|
+
<ul class="mt-1 max-h-40 overflow-auto text-xs" style="color: var(--muted-foreground);">
|
|
185
|
+
{#each report.missingDiskRecords as rec}
|
|
186
|
+
<li class="truncate py-0.5">{rec.table}: {rec.url}</li>
|
|
187
|
+
{/each}
|
|
188
|
+
</ul>
|
|
189
|
+
</details>
|
|
190
|
+
{:else}
|
|
191
|
+
<p class="text-sm" style="color: var(--success, #3A8A5C);">Wszystko w porządku</p>
|
|
192
|
+
{/if}
|
|
193
|
+
</Card.Content>
|
|
194
|
+
</Card.Root>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<!-- Refresh button -->
|
|
198
|
+
<div class="mt-6">
|
|
199
|
+
<Button variant="outline" size="sm" onclick={loadReport} disabled={loading}>
|
|
200
|
+
<Refresh class="size-4" />
|
|
201
|
+
Odśwież raport
|
|
202
|
+
</Button>
|
|
203
|
+
</div>
|
|
204
|
+
{/if}
|
|
205
|
+
</div>
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
focusedPath?: string | null;
|
|
26
26
|
flashingPath?: string | null;
|
|
27
27
|
depth?: number;
|
|
28
|
+
distributed?: boolean;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
let { field, form, path, objectFieldType = 'default', focusedPath = null, flashingPath = null, depth = 0, ...props }: Props = $props();
|
|
31
|
+
let { field, form, path, objectFieldType = 'default', focusedPath = null, flashingPath = null, depth = 0, distributed = false, ...props }: Props = $props();
|
|
31
32
|
|
|
32
33
|
const interfaceLanguage = useInterfaceLanguage();
|
|
33
34
|
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
<ArrayField {field} bind:value={$value} />
|
|
138
139
|
{/await}
|
|
139
140
|
{:else if field.type === 'object'}
|
|
140
|
-
<LazyField loader={() => import('./object-field.svelte')} props={{ field, form, path, objectFieldType, focusedPath, flashingPath, depth }} skeletonClass="h-20" />
|
|
141
|
+
<LazyField loader={() => import('./object-field.svelte')} props={{ field, form, path, objectFieldType: distributed ? 'distributed' : objectFieldType, focusedPath, flashingPath, depth }} skeletonClass="h-20" />
|
|
141
142
|
{:else if field.type === 'slug'}
|
|
142
143
|
<LazyField loader={() => import('./slug-field.svelte')} props={{ field, form, path }} skeletonClass="h-10" />
|
|
143
144
|
{:else if field.type === 'boolean'}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { cn } from '../../../utils.js';
|
|
16
16
|
|
|
17
17
|
type Props = {
|
|
18
|
-
objectFieldType?: 'default' | 'inline';
|
|
18
|
+
objectFieldType?: 'default' | 'inline' | 'distributed';
|
|
19
19
|
field: ObjectField;
|
|
20
20
|
form: SuperForm<Record<string, unknown>>;
|
|
21
21
|
path: FormPathLeaves<Record<string, unknown>>;
|
|
@@ -83,11 +83,11 @@
|
|
|
83
83
|
</div>
|
|
84
84
|
{/snippet}
|
|
85
85
|
|
|
86
|
-
{#if objectFieldType === '
|
|
86
|
+
{#if objectFieldType === 'distributed'}
|
|
87
|
+
<!-- Distributed mode: layout handles field rendering, object only initializes data -->
|
|
88
|
+
{:else if objectFieldType === 'inline'}
|
|
87
89
|
{@render content()}
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
{#if objectFieldType === 'default'}
|
|
90
|
+
{:else if objectFieldType === 'default'}
|
|
91
91
|
{#if depth >= 1}
|
|
92
92
|
<!-- Nested: lighter style without border -->
|
|
93
93
|
<div class="space-y-3 pt-1">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type FormPathLeaves, type SuperForm } from 'sveltekit-superforms';
|
|
2
2
|
import type { ObjectField } from '../../../types/fields.js';
|
|
3
3
|
type Props = {
|
|
4
|
-
objectFieldType?: 'default' | 'inline';
|
|
4
|
+
objectFieldType?: 'default' | 'inline' | 'distributed';
|
|
5
5
|
field: ObjectField;
|
|
6
6
|
form: SuperForm<Record<string, unknown>>;
|
|
7
7
|
path: FormPathLeaves<Record<string, unknown>>;
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { joinPath, setAtPath } from '../../utils/objectPath.js';
|
|
16
16
|
import { getContentLanguage } from '../../state/content-language.svelte.js';
|
|
17
17
|
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
18
|
+
import { getContext } from 'svelte';
|
|
18
19
|
import { getLocalizedLabel } from '../../utils/collectionLabel.js';
|
|
19
20
|
import RequiredLabel from './required-label.svelte';
|
|
20
21
|
import ClipboardIcon from '@tabler/icons-svelte/icons/clipboard';
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
|
|
24
25
|
const contentLanguage = getContentLanguage();
|
|
25
26
|
const interfaceLanguage = useInterfaceLanguage();
|
|
27
|
+
const inInlineBlock = getContext<boolean>('inInlineBlock') ?? false;
|
|
26
28
|
|
|
27
29
|
type Props = {
|
|
28
30
|
field: TextFieldType | RichtextFieldType | ContentFieldType;
|
|
@@ -160,7 +162,7 @@
|
|
|
160
162
|
{#if field.label}
|
|
161
163
|
<div class="flex items-center gap-1.5">
|
|
162
164
|
<RequiredLabel required={field.required}>{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
|
|
163
|
-
{#if isMultiLang && (field.required || contentLanguage.all.some(l => getLangFillStatus(l)))}
|
|
165
|
+
{#if isMultiLang && !inInlineBlock && (field.required || contentLanguage.all.some(l => getLangFillStatus(l)))}
|
|
164
166
|
<span
|
|
165
167
|
class="inline-flex items-center gap-1"
|
|
166
168
|
title={dotTooltip()}
|
|
@@ -176,7 +178,7 @@
|
|
|
176
178
|
</div>
|
|
177
179
|
{/if}
|
|
178
180
|
|
|
179
|
-
{#if contentLanguage.referenceMode && isMultiLang}
|
|
181
|
+
{#if contentLanguage.referenceMode && isMultiLang && !inInlineBlock}
|
|
180
182
|
{@const refSource = findSourceLang(lang)}
|
|
181
183
|
{#if refSource}
|
|
182
184
|
{@const refText = getReferenceText(refSource)}
|
|
@@ -199,7 +201,7 @@
|
|
|
199
201
|
<div class="h-32 animate-pulse rounded-md bg-accent"></div>
|
|
200
202
|
{/if}
|
|
201
203
|
|
|
202
|
-
{#if isMultiLang}
|
|
204
|
+
{#if isMultiLang && !inInlineBlock}
|
|
203
205
|
{@const currentVal = resolvePathValue($formData, joinPath(path, lang))}
|
|
204
206
|
{@const sourceLang = isValueEmpty(currentVal) ? findSourceLang(lang) : null}
|
|
205
207
|
{#if sourceLang}
|