includio-cms 0.0.66 → 0.0.68

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 (66) hide show
  1. package/dist/admin/client/entry/entry-form.svelte +1 -1
  2. package/dist/admin/client/entry/entry-form.svelte.d.ts +1 -1
  3. package/dist/admin/client/entry/entry-header.svelte +6 -4
  4. package/dist/admin/client/entry/entry.svelte +43 -63
  5. package/dist/admin/client/entry/hybrid/hybrid-context.svelte.d.ts +1 -0
  6. package/dist/admin/client/entry/hybrid/hybrid-preview.svelte +69 -0
  7. package/dist/admin/client/entry/hybrid/hybrid-preview.svelte.d.ts +16 -0
  8. package/dist/admin/components/fields/field-renderer.svelte +8 -14
  9. package/dist/admin/components/fields/image-field.svelte +6 -5
  10. package/dist/admin/components/fields/lazy-field.svelte +24 -0
  11. package/dist/admin/components/fields/lazy-field.svelte.d.ts +11 -0
  12. package/dist/admin/components/fields/text-field-wrapper.svelte +10 -7
  13. package/dist/admin/components/media/file/file-details.svelte +3 -1
  14. package/dist/admin/components/media/focal-point-input.svelte +106 -0
  15. package/dist/admin/components/media/focal-point-input.svelte.d.ts +7 -0
  16. package/dist/admin/remote/media.remote.d.ts +5 -0
  17. package/dist/admin/remote/media.remote.js +23 -0
  18. package/dist/cms/runtime/types.d.ts +16 -1
  19. package/dist/cms/runtime/types.js +3 -0
  20. package/dist/core/cms.d.ts +2 -1
  21. package/dist/core/cms.js +2 -0
  22. package/dist/core/server/fields/resolveImageFields.js +10 -5
  23. package/dist/core/server/fields/utils/imageStyles.d.ts +9 -2
  24. package/dist/core/server/fields/utils/imageStyles.js +87 -20
  25. package/dist/core/server/generator/fields.js +9 -3
  26. package/dist/core/server/generator/generator.js +4 -7
  27. package/dist/core/server/generator/utils.d.ts +1 -0
  28. package/dist/core/server/generator/utils.js +6 -0
  29. package/dist/core/server/media/operations/replaceFile.js +18 -2
  30. package/dist/core/server/media/operations/uploadFile.js +50 -1
  31. package/dist/core/server/media/styles/operations/generateDefaultStyles.d.ts +3 -0
  32. package/dist/core/server/media/styles/operations/generateDefaultStyles.js +26 -0
  33. package/dist/core/server/media/styles/sharp/generateImageStyle.js +36 -7
  34. package/dist/core/server/media/utils/calculateFocalCropRegion.d.ts +11 -0
  35. package/dist/core/server/media/utils/calculateFocalCropRegion.js +26 -0
  36. package/dist/core/server/media/utils/generateBlurDataUrl.d.ts +1 -0
  37. package/dist/core/server/media/utils/generateBlurDataUrl.js +5 -0
  38. package/dist/db-postgres/index.js +1 -1
  39. package/dist/db-postgres/schema/imageStyle.d.ts +17 -0
  40. package/dist/db-postgres/schema/imageStyle.js +1 -0
  41. package/dist/db-postgres/schema/mediaFile.d.ts +51 -0
  42. package/dist/db-postgres/schema/mediaFile.js +4 -1
  43. package/dist/sveltekit/components/image.svelte +26 -2
  44. package/dist/sveltekit/components/image.svelte.d.ts +1 -0
  45. package/dist/sveltekit/components/preview.svelte +22 -14
  46. package/dist/sveltekit/components/preview.svelte.d.ts +38 -8
  47. package/dist/sveltekit/index.d.ts +1 -0
  48. package/dist/sveltekit/index.js +1 -0
  49. package/dist/sveltekit/utils/getLink.d.ts +7 -0
  50. package/dist/sveltekit/utils/getLink.js +32 -0
  51. package/dist/sveltekit/utils/index.d.ts +2 -0
  52. package/dist/sveltekit/utils/index.js +2 -0
  53. package/dist/sveltekit/utils/media.d.ts +3 -0
  54. package/dist/sveltekit/utils/media.js +6 -0
  55. package/dist/types/cms.d.ts +6 -0
  56. package/dist/types/fields.d.ts +4 -0
  57. package/dist/types/index.d.ts +8 -1
  58. package/dist/types/index.js +7 -0
  59. package/dist/types/media.d.ts +8 -0
  60. package/dist/updates/0.0.67/index.d.ts +2 -0
  61. package/dist/updates/0.0.67/index.js +40 -0
  62. package/dist/updates/0.0.67/migration.sql +9 -0
  63. package/dist/updates/0.0.68/index.d.ts +2 -0
  64. package/dist/updates/0.0.68/index.js +21 -0
  65. package/dist/updates/index.js +3 -1
  66. package/package.json +1 -1
@@ -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;
@@ -7,8 +7,6 @@
7
7
  import type { InterfaceLanguage } from '../../../types/languages.js';
8
8
  import StatusBadge from './header/status-badge.svelte';
9
9
  import SaveIndicator from './header/save-indicator.svelte';
10
- import PublishPanel from './header/publish-panel.svelte';
11
- import VersionHistorySheet from './header/version-history-sheet.svelte';
12
10
  import type { UpdateEntryVersionCommandType } from '../../../core/server/entries/operations/update.js';
13
11
  import LayoutSidebar from '@tabler/icons-svelte/icons/layout-sidebar';
14
12
  import { hasHybridContext, getHybridContext } from './hybrid/hybrid-context.svelte.js';
@@ -108,9 +106,13 @@
108
106
  {primaryButtonLabel}
109
107
  </Button>
110
108
 
111
- <PublishPanel {entry} {version} {onSave} {onArchive} />
109
+ {#await import('./header/publish-panel.svelte') then { default: PublishPanel }}
110
+ <PublishPanel {entry} {version} {onSave} {onArchive} />
111
+ {/await}
112
112
 
113
- <VersionHistorySheet {entry} currentVersionId={version.id} />
113
+ {#await import('./header/version-history-sheet.svelte') then { default: VersionHistorySheet }}
114
+ <VersionHistorySheet {entry} currentVersionId={version.id} />
115
+ {/await}
114
116
 
115
117
  {#if hybridContext?.enabled}
116
118
  <Button
@@ -14,20 +14,12 @@
14
14
  import { zod4, zod4Client } from 'sveltekit-superforms/adapters';
15
15
  import { generateZodSchemaFromFields } from '../../../core/fields/fieldSchemaToTs.js';
16
16
  import type { DbEntryVersion, RawEntry } from '../../../types/entries.js';
17
- import * as ToggleGroup from '../../../components/ui/toggle-group/index.js';
18
17
  import EntryForm from './entry-form.svelte';
19
- import DeviceTablet from '@tabler/icons-svelte/icons/device-tablet';
20
- import DeviceMobile from '@tabler/icons-svelte/icons/device-mobile';
21
- import Button from '../../../components/ui/button/button.svelte';
22
- import Link from '@tabler/icons-svelte/icons/link';
23
- import DeviceDesktop from '@tabler/icons-svelte/icons/device-desktop';
24
- import ArrowsMaximize from '@tabler/icons-svelte/icons/arrows-maximize';
25
18
  import type { UpdateEntryVersionCommandType } from '../../../core/server/entries/operations/update.js';
26
19
  import { getRawCollectionEntryLabel } from '../../utils/entryLabel.js';
27
20
  import type { Field } from '../../../types/fields.js';
28
21
  import type { ValidationErrors } from 'sveltekit-superforms';
29
22
  import { createHybridContext } from './hybrid/hybrid-context.svelte.js';
30
- import HybridLayout from './hybrid/hybrid-layout.svelte';
31
23
  import { onMount } from 'svelte';
32
24
  import { get } from 'svelte/store';
33
25
 
@@ -298,8 +290,18 @@
298
290
 
299
291
  let sizePreset: SizePreset = $state('responsive');
300
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
+
301
303
  const updatePreview = useDebounce(
302
- async (window: Window, form: SuperForm<any>) => {
304
+ async (window: Window, form: SuperForm<Record<string, unknown>>) => {
303
305
  const data = await form.validateForm();
304
306
 
305
307
  if (data.valid) {
@@ -314,7 +316,7 @@
314
316
  type: 'preview-update',
315
317
  data: updatedData
316
318
  },
317
- '*'
319
+ previewOrigin
318
320
  );
319
321
  }
320
322
  },
@@ -329,6 +331,7 @@
329
331
 
330
332
  // Subscribe to form changes for preview updates
331
333
  onMount(() => {
334
+ if (!collection.previewUrl) return;
332
335
  const unsub = form.form.subscribe(() => {
333
336
  onUpdate();
334
337
  });
@@ -347,7 +350,7 @@
347
350
  // Send hybrid mode state first
348
351
  previewIframe.contentWindow.postMessage(
349
352
  { type: 'hybrid-mode-enable', enabled: hybridContext.mode === 'hybrid' },
350
- '*'
353
+ previewOrigin
351
354
  );
352
355
 
353
356
  // Then send form data
@@ -360,7 +363,7 @@
360
363
  });
361
364
  previewIframe.contentWindow.postMessage(
362
365
  { type: 'preview-update', data: updatedData },
363
- '*'
366
+ previewOrigin
364
367
  );
365
368
  }
366
369
  }
@@ -376,6 +379,7 @@
376
379
  // Listen for messages from iframe
377
380
  onMount(() => {
378
381
  function handleMessage(e: MessageEvent) {
382
+ if (e.origin !== previewOrigin) return;
379
383
  // Click on preview element - debounced to prevent scroll jumping
380
384
  if (e.data.type === 'hybrid-focus' && e.data.path) {
381
385
  debouncedSetFocusPath(e.data.path);
@@ -394,7 +398,7 @@
394
398
  if (previewIframe?.contentWindow) {
395
399
  previewIframe.contentWindow.postMessage(
396
400
  { type: 'hybrid-highlight', path: hybridContext.focusedPath },
397
- '*'
401
+ previewOrigin
398
402
  );
399
403
  }
400
404
  });
@@ -407,7 +411,7 @@
407
411
  if (previewIframe?.contentWindow) {
408
412
  previewIframe.contentWindow.postMessage(
409
413
  { type: 'hybrid-mode-enable', enabled: hybridContext.mode === 'hybrid' },
410
- '*'
414
+ previewOrigin
411
415
  );
412
416
  }
413
417
  }
@@ -418,55 +422,31 @@
418
422
 
419
423
  {#if hybridContext.mode === 'hybrid' && collection.previewUrl}
420
424
  <div class="flex min-h-0 flex-1 overflow-hidden">
421
- <HybridLayout>
422
- {#snippet preview()}
423
- <div class="flex h-full flex-col overflow-hidden">
424
- <div class="flex items-center justify-center gap-2 border-b px-2 py-1.5">
425
- <ToggleGroup.Root type="single" bind:value={sizePreset}>
426
- <ToggleGroup.Item value="responsive" aria-label="Responsive view">
427
- <ArrowsMaximize class="size-4" />
428
- </ToggleGroup.Item>
429
- <ToggleGroup.Item value="desktop" aria-label="Desktop view">
430
- <DeviceDesktop class="size-4" />
431
- </ToggleGroup.Item>
432
- <ToggleGroup.Item value="tablet" aria-label="Tablet view">
433
- <DeviceTablet class="size-4" />
434
- </ToggleGroup.Item>
435
- <ToggleGroup.Item value="mobile" aria-label="Mobile view">
436
- <DeviceMobile class="size-4" />
437
- </ToggleGroup.Item>
438
- </ToggleGroup.Root>
439
- <Button
440
- variant="ghost"
441
- size="icon"
442
- href="{collection.previewUrl}?preview={editingEntry.id}"
443
- target="_blank"
444
- >
445
- <Link class="size-4" />
446
- </Button>
447
- </div>
448
- <div class="relative flex-1 overflow-hidden" bind:this={el}>
449
- <iframe
450
- bind:this={previewIframe}
451
- style={sizePreset !== 'responsive' ? `transform: scale(${size.width / sizePresets[sizePreset][0]});` : ''}
452
- class="{sizePreset === 'responsive' ? 'h-full w-full' : 'absolute top-0 left-0 origin-top-left'} border-0"
453
- width={sizePreset !== 'responsive' ? sizePresets[sizePreset][0] : undefined}
454
- height={sizePreset !== 'responsive' ? sizePresets[sizePreset][1] : undefined}
455
- src="{collection.previewUrl}?preview={editingEntry.id}"
456
- title="preview"
457
- ></iframe>
458
- </div>
459
- </div>
460
- {/snippet}
461
- {#snippet formPanel()}
462
- <EntryForm
463
- {form}
464
- {entry}
465
- focusedPath={hybridContext.focusedPath}
466
- onPathSelect={(path) => hybridContext.focusedPath = path}
467
- />
468
- {/snippet}
469
- </HybridLayout>
425
+ {#await import('./hybrid/hybrid-layout.svelte')}
426
+ <div class="h-full animate-pulse rounded-md bg-accent"></div>
427
+ {:then { default: HybridLayout }}
428
+ <HybridLayout>
429
+ {#snippet preview()}
430
+ {#await import('./hybrid/hybrid-preview.svelte')}
431
+ <div class="h-full animate-pulse rounded-md bg-accent"></div>
432
+ {:then { default: HybridPreview }}
433
+ <HybridPreview {collection} {editingEntry} bind:previewIframe bind:sizePreset {size} bind:el />
434
+ {:catch}
435
+ <p class="p-4 text-sm text-destructive">Failed to load preview</p>
436
+ {/await}
437
+ {/snippet}
438
+ {#snippet formPanel()}
439
+ <EntryForm
440
+ {form}
441
+ {entry}
442
+ focusedPath={hybridContext.focusedPath}
443
+ onPathSelect={(path) => hybridContext.focusedPath = path}
444
+ />
445
+ {/snippet}
446
+ </HybridLayout>
447
+ {:catch}
448
+ <p class="p-4 text-sm text-destructive">Failed to load layout</p>
449
+ {/await}
470
450
  </div>
471
451
  {:else}
472
452
  <div class="flex items-stretch justify-center">
@@ -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;
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ import * as ToggleGroup from '../../../../components/ui/toggle-group/index.js';
3
+ import Button from '../../../../components/ui/button/button.svelte';
4
+ import DeviceTablet from '@tabler/icons-svelte/icons/device-tablet';
5
+ import DeviceMobile from '@tabler/icons-svelte/icons/device-mobile';
6
+ import DeviceDesktop from '@tabler/icons-svelte/icons/device-desktop';
7
+ import ArrowsMaximize from '@tabler/icons-svelte/icons/arrows-maximize';
8
+ import Link from '@tabler/icons-svelte/icons/link';
9
+ import type { DbEntryVersion } from '../../../../types/entries.js';
10
+ import type { ElementSize } from 'runed';
11
+
12
+ type SizePreset = 'responsive' | 'desktop' | 'tablet' | 'mobile';
13
+
14
+ type Props = {
15
+ collection: { previewUrl?: string };
16
+ editingEntry: DbEntryVersion;
17
+ previewIframe?: HTMLIFrameElement;
18
+ sizePreset: SizePreset;
19
+ size: ElementSize;
20
+ el?: HTMLElement;
21
+ };
22
+
23
+ let { collection, editingEntry, previewIframe = $bindable(), sizePreset = $bindable('responsive'), size, el = $bindable() }: Props = $props();
24
+
25
+ const sizePresets = {
26
+ responsive: null,
27
+ desktop: [1440, 925],
28
+ tablet: [768, 1024],
29
+ mobile: [375, 667]
30
+ } as const;
31
+ </script>
32
+
33
+ <div class="flex h-full flex-col overflow-hidden">
34
+ <div class="flex items-center justify-center gap-2 border-b px-2 py-1.5">
35
+ <ToggleGroup.Root type="single" bind:value={sizePreset}>
36
+ <ToggleGroup.Item value="responsive" aria-label="Responsive view">
37
+ <ArrowsMaximize class="size-4" />
38
+ </ToggleGroup.Item>
39
+ <ToggleGroup.Item value="desktop" aria-label="Desktop view">
40
+ <DeviceDesktop class="size-4" />
41
+ </ToggleGroup.Item>
42
+ <ToggleGroup.Item value="tablet" aria-label="Tablet view">
43
+ <DeviceTablet class="size-4" />
44
+ </ToggleGroup.Item>
45
+ <ToggleGroup.Item value="mobile" aria-label="Mobile view">
46
+ <DeviceMobile class="size-4" />
47
+ </ToggleGroup.Item>
48
+ </ToggleGroup.Root>
49
+ <Button
50
+ variant="ghost"
51
+ size="icon"
52
+ href="{collection.previewUrl}?preview={editingEntry.id}"
53
+ target="_blank"
54
+ >
55
+ <Link class="size-4" />
56
+ </Button>
57
+ </div>
58
+ <div class="relative flex-1 overflow-hidden" bind:this={el}>
59
+ <iframe
60
+ bind:this={previewIframe}
61
+ style={sizePreset !== 'responsive' ? `transform: scale(${size.width / sizePresets[sizePreset][0]});` : ''}
62
+ class="{sizePreset === 'responsive' ? 'h-full w-full' : 'absolute top-0 left-0 origin-top-left'} border-0"
63
+ width={sizePreset !== 'responsive' ? sizePresets[sizePreset][0] : undefined}
64
+ height={sizePreset !== 'responsive' ? sizePresets[sizePreset][1] : undefined}
65
+ src="{collection.previewUrl}?preview={editingEntry.id}"
66
+ title="preview"
67
+ ></iframe>
68
+ </div>
69
+ </div>
@@ -0,0 +1,16 @@
1
+ import type { DbEntryVersion } from '../../../../types/entries.js';
2
+ import type { ElementSize } from 'runed';
3
+ type SizePreset = 'responsive' | 'desktop' | 'tablet' | 'mobile';
4
+ type Props = {
5
+ collection: {
6
+ previewUrl?: string;
7
+ };
8
+ editingEntry: DbEntryVersion;
9
+ previewIframe?: HTMLIFrameElement;
10
+ sizePreset: SizePreset;
11
+ size: ElementSize;
12
+ el?: HTMLElement;
13
+ };
14
+ declare const HybridPreview: import("svelte").Component<Props, {}, "previewIframe" | "sizePreset" | "el">;
15
+ type HybridPreview = ReturnType<typeof HybridPreview>;
16
+ export default HybridPreview;
@@ -2,10 +2,6 @@
2
2
  import * as Form from '../../../components/ui/form/index.js';
3
3
 
4
4
  import type { FormPathLeaves, SuperForm } from 'sveltekit-superforms';
5
- import ImageField from './image-field.svelte';
6
- import MediaFieldComponent from './media-field.svelte';
7
- import ArrayField from './array-field.svelte';
8
- import ObjectField from './object-field.svelte';
9
5
  import SlugField from './slug-field.svelte';
10
6
  import type { Field, FieldType } from '../../../types/fields.js';
11
7
  import TextFieldWrapper from './text-field-wrapper.svelte';
@@ -13,14 +9,12 @@
13
9
  import BooleanField from './boolean-field.svelte';
14
10
  import RadioField from './radio-field.svelte';
15
11
  import CheckboxesField from './checkboxes-field.svelte';
16
- import FileField from './file-field.svelte';
17
12
  import NumberField from './number-field.svelte';
18
13
  import DateField from './date-field.svelte';
19
14
  import DateTimeField from './datetime-field.svelte';
20
15
  import SelectField from './select-field.svelte';
21
- import SeoField from './seo-field.svelte';
22
- import RelationField from './relation-field.svelte';
23
16
  import UrlFieldWrapper from './url-field-wrapper.svelte';
17
+ import LazyField from './lazy-field.svelte';
24
18
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
25
19
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
26
20
 
@@ -105,15 +99,15 @@
105
99
  {/if}
106
100
 
107
101
  {#if field.type === 'image'}
108
- <ImageField {field} {form} {path} {...props} />
102
+ <LazyField loader={() => import('./image-field.svelte')} props={{ field, form, path, ...props }} skeletonClass="h-24" />
109
103
  {:else if field.type === 'media'}
110
- <MediaFieldComponent {field} {form} {path} {...props} />
104
+ <LazyField loader={() => import('./media-field.svelte')} props={{ field, form, path, ...props }} skeletonClass="h-24" />
111
105
  {:else if field.type === 'file'}
112
- <FileField {field} {form} {path} {...props} />
106
+ <LazyField loader={() => import('./file-field.svelte')} props={{ field, form, path, ...props }} skeletonClass="h-12" />
113
107
  {:else if field.type === 'array'}
114
- <ArrayField {field} {form} {path} {focusedPath} {flashingPath} {depth} {...props} />
108
+ <LazyField loader={() => import('./array-field.svelte')} props={{ field, form, path, focusedPath, flashingPath, depth, ...props }} skeletonClass="h-20" />
115
109
  {:else if field.type === 'object'}
116
- <ObjectField {field} {form} {path} {objectFieldType} {focusedPath} {flashingPath} {depth} {...props} />
110
+ <LazyField loader={() => import('./object-field.svelte')} props={{ field, form, path, objectFieldType, focusedPath, flashingPath, depth, ...props }} skeletonClass="h-20" />
117
111
  {:else if field.type === 'slug'}
118
112
  <SlugField {field} {form} {path} {...props} />
119
113
  {:else if field.type === 'boolean'}
@@ -121,11 +115,11 @@
121
115
  {:else if field.type === 'number'}
122
116
  <NumberField {field} {form} {path} {...props} />
123
117
  {:else if field.type === 'seo'}
124
- <SeoField {field} {form} {path} {...props} />
118
+ <LazyField loader={() => import('./seo-field.svelte')} props={{ field, form, path, ...props }} skeletonClass="h-16" />
125
119
  {:else if field.type === 'url'}
126
120
  <UrlFieldWrapper {field} {form} {path} {...props} />
127
121
  {:else if field.type === 'relation'}
128
- <RelationField {field} {form} {path} {...props} />
122
+ <LazyField loader={() => import('./relation-field.svelte')} props={{ field, form, path, ...props }} skeletonClass="h-16" />
129
123
  {:else if field.type === 'date'}
130
124
  <DateField {field} {form} {path} {...props} />
131
125
  {:else if field.type === 'datetime'}
@@ -35,7 +35,6 @@
35
35
  let currentIndex = $state(0);
36
36
  let lightboxOpen = $state(false);
37
37
  let lightboxFile = $state<MediaFile | null>(null);
38
-
39
38
  function openLightbox(file: MediaFile) {
40
39
  lightboxFile = file;
41
40
  lightboxOpen = true;
@@ -139,10 +138,12 @@
139
138
  <div
140
139
  class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/60 to-transparent p-2 pt-8"
141
140
  >
142
- <Button {...props} size="sm" variant="secondary" class="h-8">
143
- <Plus class="h-4 w-4" />
144
- {lang[interfaceLanguage.current].change}
145
- </Button>
141
+ <div class="flex gap-1">
142
+ <Button {...props} size="sm" variant="secondary" class="h-8">
143
+ <Plus class="h-4 w-4" />
144
+ {lang[interfaceLanguage.current].change}
145
+ </Button>
146
+ </div>
146
147
  {#if !field.required}
147
148
  <Button
148
149
  variant="destructive"
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { Component } from 'svelte';
3
+
4
+ type Props = {
5
+ loader: () => Promise<{ default: Component<any> }>;
6
+ props: Record<string, any>;
7
+ skeletonClass?: string;
8
+ };
9
+
10
+ let { loader, props: fieldProps, skeletonClass = 'h-10' }: Props = $props();
11
+ let Comp = $state<Component<any> | null>(null);
12
+
13
+ $effect(() => {
14
+ loader().then((m) => {
15
+ Comp = m.default;
16
+ });
17
+ });
18
+ </script>
19
+
20
+ {#if Comp}
21
+ <Comp {...fieldProps} />
22
+ {:else}
23
+ <div class="{skeletonClass} animate-pulse rounded-md bg-accent"></div>
24
+ {/if}
@@ -0,0 +1,11 @@
1
+ import type { Component } from 'svelte';
2
+ type Props = {
3
+ loader: () => Promise<{
4
+ default: Component<any>;
5
+ }>;
6
+ props: Record<string, any>;
7
+ skeletonClass?: string;
8
+ };
9
+ declare const LazyField: Component<Props, {}, "">;
10
+ type LazyField = ReturnType<typeof LazyField>;
11
+ export default LazyField;
@@ -12,7 +12,6 @@
12
12
  import { type FormPathLeaves, type SuperForm } from 'sveltekit-superforms';
13
13
  import TextField from './text-field.svelte';
14
14
  import { joinPath } from '../../utils/objectPath.js';
15
- import RichtextField from './richtext-field.svelte';
16
15
  import { getContentLanguage } from '../../state/content-language.svelte.js';
17
16
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
18
17
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
@@ -53,12 +52,16 @@
53
52
  path={joinPath(path, lang) as FormPathLeaves<T, string | undefined>}
54
53
  />
55
54
  {:else if field.type === 'richtext'}
56
- <RichtextField
57
- {...props}
58
- {field}
59
- {form}
60
- path={joinPath(path, lang) as FormPathLeaves<T, string | undefined>}
61
- />
55
+ {#await import('./richtext-field.svelte') then { default: RichtextField }}
56
+ <RichtextField
57
+ {...props}
58
+ {field}
59
+ {form}
60
+ path={joinPath(path, lang) as FormPathLeaves<T, string | undefined>}
61
+ />
62
+ {:catch}
63
+ <div class="h-32 animate-pulse rounded-md bg-accent"></div>
64
+ {/await}
62
65
  {/if}
63
66
  {/snippet}
64
67
  </Form.Control>
@@ -6,6 +6,7 @@
6
6
  import type { InterfaceLanguage } from '../../../../types/languages.js';
7
7
  import type { MediaFile, MediaTag } from '../../../../types/media.js';
8
8
  import AltInput from '../alt-input.svelte';
9
+ import FocalPointInput from '../focal-point-input.svelte';
9
10
  import TagCombobox from '../tag-combobox.svelte';
10
11
  import FileMiniature from './file-miniature.svelte';
11
12
  import MediaSelector from '../media-selector.svelte';
@@ -276,12 +277,13 @@
276
277
  </InputGroup.Root>
277
278
  </div>
278
279
 
279
- <!-- Alt text dla obrazów -->
280
+ <!-- Alt text + focal point for images -->
280
281
  {#if file.type === 'image'}
281
282
  <div class="space-y-1.5">
282
283
  <Label class="text-xs">{lang[interfaceLanguage.current].fileAltLabel}</Label>
283
284
  <AltInput alt={file.alt} fileId={file.id} />
284
285
  </div>
286
+ <FocalPointInput {file} />
285
287
  {/if}
286
288
 
287
289
  <!-- Accessibility section for video -->
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ import Button from '../../../components/ui/button/button.svelte';
3
+ import { getRemotes } from '../../context/remotes.js';
4
+ import { toast } from 'svelte-sonner';
5
+ import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
6
+ import type { InterfaceLanguage } from '../../../types/languages.js';
7
+ import type { MediaFile } from '../../../types/media.js';
8
+ import Focus from '@tabler/icons-svelte/icons/focus-2';
9
+
10
+ const interfaceLanguage = useInterfaceLanguage();
11
+ const lang: Record<InterfaceLanguage, { label: string; save: string; success: string; error: string }> = {
12
+ pl: {
13
+ label: 'Punkt ogniskowy',
14
+ save: 'Zapisz punkt ogniskowy',
15
+ success: 'Punkt ogniskowy zapisany.',
16
+ error: 'Nie udało się zapisać punktu ogniskowego.'
17
+ },
18
+ en: {
19
+ label: 'Focal point',
20
+ save: 'Save focal point',
21
+ success: 'Focal point saved.',
22
+ error: 'Failed to save focal point.'
23
+ }
24
+ };
25
+
26
+ const remotes = getRemotes();
27
+
28
+ type Props = {
29
+ file: MediaFile;
30
+ };
31
+
32
+ let { file }: Props = $props();
33
+
34
+ let focalX = $state(file.focalX ?? 0.5);
35
+ let focalY = $state(file.focalY ?? 0.5);
36
+ let saving = $state(false);
37
+ let container: HTMLDivElement;
38
+
39
+ function handlePointer(e: PointerEvent) {
40
+ if (!container) return;
41
+ const rect = container.getBoundingClientRect();
42
+ focalX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
43
+ focalY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
44
+ }
45
+
46
+ function onPointerDown(e: PointerEvent) {
47
+ container.setPointerCapture(e.pointerId);
48
+ handlePointer(e);
49
+ }
50
+
51
+ function onPointerMove(e: PointerEvent) {
52
+ if (container.hasPointerCapture(e.pointerId)) {
53
+ handlePointer(e);
54
+ }
55
+ }
56
+
57
+ async function save() {
58
+ saving = true;
59
+ try {
60
+ await remotes.setFocalPoint({ fileId: file.id, focalX, focalY });
61
+ file.focalX = focalX;
62
+ file.focalY = focalY;
63
+ toast.success(lang[interfaceLanguage.current].success);
64
+ } catch {
65
+ toast.error(lang[interfaceLanguage.current].error);
66
+ } finally {
67
+ saving = false;
68
+ }
69
+ }
70
+
71
+ // Reset when file changes
72
+ $effect(() => {
73
+ focalX = file.focalX ?? 0.5;
74
+ focalY = file.focalY ?? 0.5;
75
+ });
76
+ </script>
77
+
78
+ <div class="space-y-2">
79
+ <div class="flex items-center gap-1.5 text-xs font-medium">
80
+ <Focus class="h-3.5 w-3.5 text-muted-foreground" />
81
+ {lang[interfaceLanguage.current].label}
82
+ </div>
83
+
84
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
85
+ <div
86
+ bind:this={container}
87
+ class="relative cursor-crosshair overflow-hidden rounded-lg border select-none"
88
+ onpointerdown={onPointerDown}
89
+ onpointermove={onPointerMove}
90
+ >
91
+ <img src={file.url} alt={file.alt || file.name} class="w-full pointer-events-none" draggable="false" />
92
+ <!-- Crosshair -->
93
+ <div
94
+ class="absolute w-6 h-6 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
95
+ style="left: {focalX * 100}%; top: {focalY * 100}%;"
96
+ >
97
+ <div class="absolute inset-0 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)]"></div>
98
+ <div class="absolute left-1/2 top-0 h-full w-px bg-white/80 -translate-x-px"></div>
99
+ <div class="absolute top-1/2 left-0 w-full h-px bg-white/80 -translate-y-px"></div>
100
+ </div>
101
+ </div>
102
+
103
+ <Button variant="outline" class="w-full" size="sm" onclick={save} disabled={saving}>
104
+ {lang[interfaceLanguage.current].save}
105
+ </Button>
106
+ </div>
@@ -0,0 +1,7 @@
1
+ import type { MediaFile } from '../../../types/media.js';
2
+ type Props = {
3
+ file: MediaFile;
4
+ };
5
+ declare const FocalPointInput: import("svelte").Component<Props, {}, "">;
6
+ type FocalPointInput = ReturnType<typeof FocalPointInput>;
7
+ export default FocalPointInput;
@@ -46,6 +46,11 @@ export declare const updateMediaAccessibility: import("@sveltejs/kit").RemoteCom
46
46
  audioDescriptionFileId?: string | null | undefined;
47
47
  posterUrl?: string | null | undefined;
48
48
  }, Promise<import("../../types/media.js").MediaFile>>;
49
+ export declare const setFocalPoint: import("@sveltejs/kit").RemoteCommand<{
50
+ fileId: string;
51
+ focalX: number;
52
+ focalY: number;
53
+ }, Promise<void>>;
49
54
  export declare const renameMediaFile: import("@sveltejs/kit").RemoteCommand<{
50
55
  fileId: string;
51
56
  newName: string;