includio-cms 0.7.2 → 0.13.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.
Files changed (185) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/ROADMAP.md +54 -2
  3. package/dist/admin/api/generate-styles.d.ts +2 -0
  4. package/dist/admin/api/generate-styles.js +32 -0
  5. package/dist/admin/api/handler.js +33 -0
  6. package/dist/admin/api/media-gc.js +10 -4
  7. package/dist/admin/api/rest/handler.js +17 -0
  8. package/dist/admin/api/rest/routes/collections.js +25 -13
  9. package/dist/admin/api/rest/routes/entries.d.ts +1 -1
  10. package/dist/admin/api/rest/routes/entries.js +10 -10
  11. package/dist/admin/api/rest/routes/media.d.ts +2 -0
  12. package/dist/admin/api/rest/routes/media.js +9 -0
  13. package/dist/admin/api/rest/routes/schema.d.ts +5 -0
  14. package/dist/admin/api/rest/routes/schema.js +152 -0
  15. package/dist/admin/api/rest/routes/singletons.d.ts +1 -1
  16. package/dist/admin/api/rest/routes/singletons.js +8 -7
  17. package/dist/admin/api/rest/routes/upload.d.ts +2 -0
  18. package/dist/admin/api/rest/routes/upload.js +28 -0
  19. package/dist/admin/api/upload.js +13 -0
  20. package/dist/admin/client/collection/collection-entries.svelte +35 -13
  21. package/dist/admin/client/entry/entry.svelte +21 -23
  22. package/dist/admin/client/entry/header/a11y-validator.js +2 -2
  23. package/dist/admin/client/entry/header/publish-panel.svelte +33 -85
  24. package/dist/admin/client/entry/header/status-badge.svelte +2 -2
  25. package/dist/admin/client/entry/header/version-history-sheet.svelte +9 -9
  26. package/dist/admin/client/entry/header/visibility.svelte +16 -10
  27. package/dist/admin/client/entry/utils.d.ts +3 -0
  28. package/dist/admin/client/entry/utils.js +22 -4
  29. package/dist/admin/client/form/form-submission/form-submission-page.svelte +4 -1
  30. package/dist/admin/client/form/form-submission/submission-field.svelte +10 -0
  31. package/dist/admin/client/index.d.ts +1 -0
  32. package/dist/admin/client/index.js +1 -0
  33. package/dist/admin/client/maintenance/maintenance-page.svelte +146 -2
  34. package/dist/admin/client/users/users-page.svelte +5 -6
  35. package/dist/admin/client/users/users-page.svelte.d.ts +1 -4
  36. package/dist/admin/components/fields/block-picker-modal.svelte +13 -4
  37. package/dist/admin/components/fields/blocks-field.svelte +40 -19
  38. package/dist/admin/components/fields/field-renderer.svelte +4 -8
  39. package/dist/admin/components/fields/object-field.svelte +7 -12
  40. package/dist/admin/components/fields/select-field.svelte +8 -2
  41. package/dist/admin/components/fields/seo-field.svelte +40 -93
  42. package/dist/admin/components/fields/simple-array-field.svelte +27 -16
  43. package/dist/admin/components/fields/text-field-wrapper.svelte +52 -197
  44. package/dist/admin/components/fields/text-field-wrapper.svelte.d.ts +2 -2
  45. package/dist/admin/components/fields/url-field-wrapper.svelte +15 -25
  46. package/dist/admin/components/fields/url-field.svelte +61 -72
  47. package/dist/admin/components/layout/layout-renderer.svelte +10 -4
  48. package/dist/admin/components/media/file-preview.svelte +10 -1
  49. package/dist/admin/components/media/file-upload.svelte +5 -1
  50. package/dist/admin/components/media/file-upload.svelte.d.ts +1 -0
  51. package/dist/admin/components/media/files-list.svelte +12 -3
  52. package/dist/admin/components/media/media-library.svelte +109 -37
  53. package/dist/admin/components/media/media-selector.svelte +90 -16
  54. package/dist/admin/components/media/tag-sidebar.svelte +10 -6
  55. package/dist/admin/components/media/tag-sidebar.svelte.d.ts +7 -2
  56. package/dist/admin/components/tiptap/FigureNodeView.svelte +15 -10
  57. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +53 -94
  58. package/dist/admin/components/tiptap/SlashCommandPopup.svelte +8 -3
  59. package/dist/admin/components/tiptap/editor-toolbar.svelte +28 -23
  60. package/dist/admin/components/tiptap/image-dialog.svelte +12 -7
  61. package/dist/admin/components/tiptap/inline-block-node.js +6 -5
  62. package/dist/admin/components/tiptap/lang.d.ts +77 -0
  63. package/dist/admin/components/tiptap/lang.js +170 -0
  64. package/dist/admin/components/tiptap/link-dialog.svelte +31 -28
  65. package/dist/admin/components/tiptap/slash-command.js +27 -23
  66. package/dist/admin/components/tiptap/table-dialog.svelte +9 -4
  67. package/dist/admin/components/tiptap/video-dialog.svelte +6 -1
  68. package/dist/admin/remote/email.remote.d.ts +1 -0
  69. package/dist/admin/remote/email.remote.js +5 -0
  70. package/dist/admin/remote/entry.remote.d.ts +2 -5
  71. package/dist/admin/remote/entry.remote.js +23 -28
  72. package/dist/admin/remote/index.d.ts +1 -0
  73. package/dist/admin/remote/index.js +1 -0
  74. package/dist/admin/remote/media.remote.d.ts +15 -0
  75. package/dist/admin/remote/media.remote.js +18 -2
  76. package/dist/admin/remote/preview.remote.js +3 -1
  77. package/dist/admin/utils/entryLabel.js +9 -6
  78. package/dist/admin/utils/translationStatus.js +1 -2
  79. package/dist/cli/scaffold/admin.js +34 -2
  80. package/dist/cms/runtime/api.d.ts +16 -12
  81. package/dist/cms/runtime/api.js +7 -6
  82. package/dist/cms/runtime/remote.js +2 -2
  83. package/dist/cms/runtime/schemas.d.ts +1 -1
  84. package/dist/cms/runtime/schemas.js +1 -1
  85. package/dist/cms/runtime/types.d.ts +118 -112
  86. package/dist/cms/runtime/types.js +0 -12
  87. package/dist/core/cms.d.ts +3 -1
  88. package/dist/core/cms.js +30 -0
  89. package/dist/core/fields/fieldSchemaToTs.js +9 -15
  90. package/dist/core/fields/formFieldSchemaToTs.js +7 -0
  91. package/dist/core/server/entries/operations/create.js +10 -4
  92. package/dist/core/server/entries/operations/get.d.ts +1 -0
  93. package/dist/core/server/entries/operations/get.js +186 -191
  94. package/dist/core/server/entries/operations/update.d.ts +6 -7
  95. package/dist/core/server/entries/operations/update.js +20 -38
  96. package/dist/core/server/fields/populateEntry.js +16 -52
  97. package/dist/core/server/fields/resolveImageFields.js +69 -120
  98. package/dist/core/server/fields/resolveRelationFields.js +30 -51
  99. package/dist/core/server/fields/resolveRichtextLinks.js +46 -100
  100. package/dist/core/server/fields/resolveTypographyOrphans.bench.d.ts +1 -0
  101. package/dist/core/server/fields/resolveTypographyOrphans.bench.js +87 -0
  102. package/dist/core/server/fields/resolveTypographyOrphans.d.ts +3 -0
  103. package/dist/core/server/fields/resolveTypographyOrphans.js +128 -0
  104. package/dist/core/server/fields/resolveUrlFields.js +47 -56
  105. package/dist/core/server/fields/utils/fixOrphans.d.ts +5 -0
  106. package/dist/core/server/fields/utils/fixOrphans.js +12 -0
  107. package/dist/core/server/fields/utils/imageStyles.d.ts +4 -2
  108. package/dist/core/server/fields/utils/imageStyles.js +41 -25
  109. package/dist/core/server/fields/utils/resolveMedia.js +1 -6
  110. package/dist/core/server/forms/submissions/operations/delete.js +26 -2
  111. package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +2 -0
  112. package/dist/core/server/forms/submissions/utils/parseMultipart.js +75 -0
  113. package/dist/core/server/generator/fields.d.ts +6 -0
  114. package/dist/core/server/generator/fields.js +43 -5
  115. package/dist/core/server/generator/formFieldSchemaToString.js +10 -0
  116. package/dist/core/server/generator/formFields.js +1 -0
  117. package/dist/core/server/generator/generator.js +98 -30
  118. package/dist/core/server/media/operations/getFiles.d.ts +5 -0
  119. package/dist/core/server/media/operations/getFiles.js +6 -0
  120. package/dist/core/server/media/operations/uploadPrivateFile.d.ts +4 -0
  121. package/dist/core/server/media/operations/uploadPrivateFile.js +8 -0
  122. package/dist/core/server/media/styles/operations/batchGenerateStyles.d.ts +16 -0
  123. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +144 -0
  124. package/dist/db-postgres/index.js +303 -37
  125. package/dist/db-postgres/schema/entry.d.ts +0 -94
  126. package/dist/db-postgres/schema/entry.js +0 -6
  127. package/dist/db-postgres/schema/entryVersion.d.ts +17 -0
  128. package/dist/db-postgres/schema/entryVersion.js +1 -0
  129. package/dist/entity/index.d.ts +9 -4
  130. package/dist/entity/index.js +24 -24
  131. package/dist/files-local/index.js +43 -0
  132. package/dist/paraglide/messages/_index.d.ts +36 -3
  133. package/dist/paraglide/messages/_index.js +71 -3
  134. package/dist/paraglide/messages/en.d.ts +5 -0
  135. package/dist/paraglide/messages/en.js +14 -0
  136. package/dist/paraglide/messages/pl.d.ts +5 -0
  137. package/dist/paraglide/messages/pl.js +14 -0
  138. package/dist/sveltekit/components/preview.svelte +2 -326
  139. package/dist/sveltekit/components/preview.svelte.d.ts +5 -16
  140. package/dist/sveltekit/server/index.d.ts +2 -1
  141. package/dist/sveltekit/server/index.js +2 -1
  142. package/dist/sveltekit/server/preview.js +4 -7
  143. package/dist/types/adapters/db.d.ts +15 -1
  144. package/dist/types/adapters/files.d.ts +6 -0
  145. package/dist/types/cms.d.ts +5 -0
  146. package/dist/types/entries.d.ts +54 -18
  147. package/dist/types/fields.d.ts +14 -24
  148. package/dist/types/formFields.d.ts +7 -2
  149. package/dist/types/index.d.ts +2 -2
  150. package/dist/types/layout.d.ts +0 -1
  151. package/dist/types/structured-content.d.ts +5 -0
  152. package/dist/updates/0.10.0/index.d.ts +2 -0
  153. package/dist/updates/0.10.0/index.js +15 -0
  154. package/dist/updates/0.11.0/index.d.ts +2 -0
  155. package/dist/updates/0.11.0/index.js +12 -0
  156. package/dist/updates/0.12.0/index.d.ts +2 -0
  157. package/dist/updates/0.12.0/index.js +12 -0
  158. package/dist/updates/0.13.0/index.d.ts +2 -0
  159. package/dist/updates/0.13.0/index.js +10 -0
  160. package/dist/updates/0.13.1/index.d.ts +2 -0
  161. package/dist/updates/0.13.1/index.js +20 -0
  162. package/dist/updates/0.7.3/index.d.ts +2 -0
  163. package/dist/updates/0.7.3/index.js +10 -0
  164. package/dist/updates/0.8.0/index.d.ts +2 -0
  165. package/dist/updates/0.8.0/index.js +18 -0
  166. package/dist/updates/0.8.0/migrate.d.ts +2 -0
  167. package/dist/updates/0.8.0/migrate.js +101 -0
  168. package/dist/updates/0.9.0/index.d.ts +2 -0
  169. package/dist/updates/0.9.0/index.js +38 -0
  170. package/dist/updates/index.js +9 -1
  171. package/package.json +7 -6
  172. package/dist/admin/components/fields/image-field.svelte +0 -198
  173. package/dist/admin/components/fields/image-field.svelte.d.ts +0 -8
  174. package/dist/admin/components/fields/richtext-field.svelte +0 -13
  175. package/dist/admin/components/fields/richtext-field.svelte.d.ts +0 -8
  176. package/dist/admin/components/tiptap.svelte +0 -11
  177. package/dist/admin/components/tiptap.svelte.d.ts +0 -6
  178. package/dist/core/server/entries/utils/getEntryTranslation.d.ts +0 -1
  179. package/dist/core/server/entries/utils/getEntryTranslation.js +0 -18
  180. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  181. package/dist/paraglide/messages/hello_world.js +0 -33
  182. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  183. package/dist/paraglide/messages/login_hello.js +0 -34
  184. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  185. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -31,9 +31,10 @@
31
31
  accept?: string;
32
32
  onUpload?: () => void;
33
33
  dropZoneRef?: HTMLElement | null;
34
+ tagIds?: string[];
34
35
  };
35
36
 
36
- let { accept, onUpload, dropZoneRef = $bindable(null) }: Props =
37
+ let { accept, onUpload, dropZoneRef = $bindable(null), tagIds }: Props =
37
38
  $props();
38
39
 
39
40
  let inputElement: HTMLInputElement;
@@ -43,6 +44,9 @@
43
44
  async function uploadFile(file: File, index: number) {
44
45
  const form = new FormData();
45
46
  form.append('file', file);
47
+ if (tagIds && tagIds.length > 0) {
48
+ form.append('tagIds', JSON.stringify(tagIds));
49
+ }
46
50
 
47
51
  return new Promise<void>((resolve) => {
48
52
  const xhr = new XMLHttpRequest();
@@ -2,6 +2,7 @@ type Props = {
2
2
  accept?: string;
3
3
  onUpload?: () => void;
4
4
  dropZoneRef?: HTMLElement | null;
5
+ tagIds?: string[];
5
6
  };
6
7
  declare const FileUpload: import("svelte").Component<Props, {}, "dropZoneRef">;
7
8
  type FileUpload = ReturnType<typeof FileUpload>;
@@ -9,6 +9,15 @@
9
9
  import Music from '@tabler/icons-svelte/icons/music';
10
10
  import Pdf from '@tabler/icons-svelte/icons/pdf';
11
11
  import File from '@tabler/icons-svelte/icons/file';
12
+ import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
13
+ import type { InterfaceLanguage } from '../../../types/languages.js';
14
+
15
+ const interfaceLanguage = useInterfaceLanguage();
16
+ const filesLang: Record<InterfaceLanguage, { noFiles: string; uploadOrFilter: string; tags: string }> = {
17
+ pl: { noFiles: 'Brak plików', uploadOrFilter: 'Prześlij pliki lub zmień filtry', tags: 'Tagi' },
18
+ en: { noFiles: 'No files', uploadOrFilter: 'Upload files or change filters', tags: 'Tags' }
19
+ };
20
+ const ft = $derived(filesLang[interfaceLanguage.current]);
12
21
 
13
22
  type Props = {
14
23
  files: MediaFile[];
@@ -94,8 +103,8 @@
94
103
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
95
104
  </svg>
96
105
  </div>
97
- <p class="text-sm font-medium text-foreground">Brak plików</p>
98
- <p class="text-xs text-muted-foreground mt-1">Prześlij pliki lub zmień filtry</p>
106
+ <p class="text-sm font-medium text-foreground">{ft.noFiles}</p>
107
+ <p class="text-xs text-muted-foreground mt-1">{ft.uploadOrFilter}</p>
99
108
  </div>
100
109
  {:else}
101
110
  {#each sortedFiles as file, i (file.id)}
@@ -146,7 +155,7 @@
146
155
  <span class="text-[11px] font-medium text-text-light">{formatFileSize(file.size)}</span>
147
156
  {/if}
148
157
  {#if file.tags && file.tags.length > 0}
149
- <span class="flex items-center gap-0.5 ml-auto" role="list" aria-label="Tagi">
158
+ <span class="flex items-center gap-0.5 ml-auto" role="list" aria-label={ft.tags}>
150
159
  {#each file.tags as tag (tag.id)}
151
160
  <span
152
161
  class="h-2 w-2 rounded-full"
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import FileUpload from './file-upload.svelte';
3
- import { onMount } from 'svelte';
3
+ import { onMount, untrack } from 'svelte';
4
4
  import type { MediaFile, MediaTag } from '../../../types/media.js';
5
5
  import { getRemotes } from '../../context/remotes.js';
6
6
  import { toast } from 'svelte-sonner';
@@ -28,6 +28,7 @@
28
28
  selectModeActive: string;
29
29
  selectModeAnnounce: string;
30
30
  selectModeOffAnnounce: string;
31
+ loadMore: string;
31
32
  }
32
33
  > = {
33
34
  pl: {
@@ -38,7 +39,8 @@
38
39
  selectMode: 'Wybierz',
39
40
  selectModeActive: 'Zaznaczanie',
40
41
  selectModeAnnounce: 'Tryb zaznaczania włączony. Klikaj pliki, aby je zaznaczyć.',
41
- selectModeOffAnnounce: 'Tryb zaznaczania wyłączony.'
42
+ selectModeOffAnnounce: 'Tryb zaznaczania wyłączony.',
43
+ loadMore: 'Wczytaj więcej'
42
44
  },
43
45
  en: {
44
46
  fileDeletedToast: 'File has been deleted',
@@ -48,7 +50,8 @@
48
50
  selectMode: 'Select',
49
51
  selectModeActive: 'Selecting',
50
52
  selectModeAnnounce: 'Selection mode enabled. Click files to select them.',
51
- selectModeOffAnnounce: 'Selection mode disabled.'
53
+ selectModeOffAnnounce: 'Selection mode disabled.',
54
+ loadMore: 'Load more'
52
55
  }
53
56
  };
54
57
 
@@ -63,6 +66,8 @@
63
66
 
64
67
  let { selected = $bindable([]), multiple = false, accept }: Props = $props();
65
68
 
69
+ const PAGE_SIZE = 48;
70
+
66
71
  let currentFile: MediaFile | null = $state(null);
67
72
  let activeTagFilter = $state<string | null>(null);
68
73
  let searchQuery = $state('');
@@ -70,6 +75,8 @@
70
75
  let dropZoneRef = $state<HTMLElement | null>(null);
71
76
  let selectionMode = $state(false);
72
77
  let selectionAnnouncement = $state('');
78
+ let page = $state(0);
79
+ let loadedFiles = $state<MediaFile[]>([]);
73
80
 
74
81
  function exitSelectionMode() {
75
82
  selectionMode = false;
@@ -83,21 +90,68 @@
83
90
  selectionAnnouncement = lang[interfaceLanguage.current].selectModeAnnounce;
84
91
  }
85
92
 
93
+ const filterData = $derived({
94
+ tagIds: activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined,
95
+ untagged: activeTagFilter === 'untagged' ? true : undefined,
96
+ mimeTypes: accept?.split(','),
97
+ search: searchQuery || undefined
98
+ });
99
+
100
+ // Reset page on filter/search change — track primitive sources only
101
+ $effect(() => {
102
+ void activeTagFilter;
103
+ void searchQuery;
104
+ void accept;
105
+ untrack(() => {
106
+ page = 0;
107
+ loadedFiles = [];
108
+ });
109
+ });
110
+
86
111
  const filesQuery = $derived(
87
112
  remotes.getMediaFiles({
88
113
  data: {
89
- tagIds: activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined,
90
- untagged: activeTagFilter === 'untagged' ? true : undefined,
91
- mimeTypes: accept?.split(','),
92
- search: searchQuery || undefined
114
+ ...filterData,
115
+ limit: PAGE_SIZE,
116
+ offset: page * PAGE_SIZE
93
117
  }
94
118
  })
95
119
  );
96
120
 
97
- const allFilesQuery = $derived(
98
- remotes.getMediaFiles({ data: { mimeTypes: accept?.split(',') } })
121
+ const countQuery = $derived(
122
+ remotes.countMediaFiles({
123
+ data: {
124
+ tagIds: filterData.tagIds,
125
+ untagged: filterData.untagged,
126
+ mimeTypes: filterData.mimeTypes,
127
+ search: filterData.search
128
+ }
129
+ })
99
130
  );
100
131
 
132
+ const tagCountsQuery = $derived(remotes.getMediaTagsWithCounts());
133
+ const totalCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(',') } }));
134
+ const untaggedCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(','), untagged: true } }));
135
+
136
+ // Append new page results — only react to filesQuery changes
137
+ $effect(() => {
138
+ const ready = filesQuery.ready;
139
+ const current = filesQuery.current;
140
+ if (ready && current) {
141
+ untrack(() => {
142
+ if (page === 0) {
143
+ loadedFiles = current;
144
+ } else {
145
+ const existingIds = new Set(loadedFiles.map((f) => f.id));
146
+ loadedFiles = [...loadedFiles, ...current.filter((f) => !existingIds.has(f.id))];
147
+ }
148
+ });
149
+ }
150
+ });
151
+
152
+ const totalCount = $derived(countQuery.ready ? (countQuery.current as number) : 0);
153
+ const hasMore = $derived(loadedFiles.length < totalCount);
154
+
101
155
  let tagsQuery = $derived(remotes.getMediaTags());
102
156
 
103
157
  function handleFileSelect(file: MediaFile, event?: MouseEvent) {
@@ -136,12 +190,21 @@
136
190
  selectedFileIds = [...merged];
137
191
  }
138
192
 
193
+ function refreshAll() {
194
+ page = 0;
195
+ loadedFiles = [];
196
+ filesQuery.refresh();
197
+ countQuery.refresh();
198
+ tagCountsQuery.refresh();
199
+ totalCountQuery.refresh();
200
+ untaggedCountQuery.refresh();
201
+ }
202
+
139
203
  async function deleteFileCommand() {
140
204
  if (currentFile) {
141
205
  await remotes.deleteMediaFile(currentFile.id);
142
206
  toast.success(lang[interfaceLanguage.current].fileDeletedToast);
143
- filesQuery.refresh();
144
- allFilesQuery.refresh();
207
+ refreshAll();
145
208
  currentFile = null;
146
209
  }
147
210
  }
@@ -153,8 +216,7 @@
153
216
  if (tags) {
154
217
  currentFile = { ...currentFile, tags: tags.filter((t) => tagIds.includes(t.id)) };
155
218
  }
156
- await filesQuery.refresh();
157
- await allFilesQuery.refresh();
219
+ refreshAll();
158
220
  }
159
221
  }
160
222
 
@@ -163,8 +225,7 @@
163
225
  const result = await remotes.renameMediaFile({ fileId: currentFile.id, newName });
164
226
  if (result.success === true) {
165
227
  currentFile = { ...currentFile, name: result.name, url: result.url };
166
- await filesQuery.refresh();
167
- await allFilesQuery.refresh();
228
+ refreshAll();
168
229
  }
169
230
  return result;
170
231
  }
@@ -174,27 +235,25 @@
174
235
  async function handleCreateTag(name: string, color: string) {
175
236
  await remotes.createMediaTag({ name, color });
176
237
  await tagsQuery.refresh();
238
+ await tagCountsQuery.refresh();
177
239
  }
178
240
 
179
241
  async function handleUpdateTag(id: string, name: string, color: string) {
180
242
  await remotes.updateMediaTag({ id, name, color });
181
243
  await tagsQuery.refresh();
182
- await filesQuery.refresh();
183
- await allFilesQuery.refresh();
244
+ refreshAll();
184
245
  }
185
246
 
186
247
  async function handleDeleteTag(id: string) {
187
248
  await remotes.deleteMediaTag(id);
188
249
  await tagsQuery.refresh();
189
- await filesQuery.refresh();
190
- await allFilesQuery.refresh();
250
+ refreshAll();
191
251
  }
192
252
 
193
253
  async function handleBulkTag(tagIds: string[]) {
194
254
  const fileIds = selectedFileIds;
195
255
  await remotes.bulkSetMediaFileTags({ fileIds, tagIds });
196
- await filesQuery.refresh();
197
- await allFilesQuery.refresh();
256
+ refreshAll();
198
257
  }
199
258
 
200
259
  async function handleBulkDelete() {
@@ -202,8 +261,7 @@
202
261
  toast.success(lang[interfaceLanguage.current].bulkDeletedToast);
203
262
  selectedFileIds = [];
204
263
  currentFile = null;
205
- await filesQuery.refresh();
206
- await allFilesQuery.refresh();
264
+ refreshAll();
207
265
  }
208
266
 
209
267
  onMount(() => {
@@ -218,10 +276,12 @@
218
276
  <div class="flex h-full overflow-hidden" bind:this={dropZoneRef}>
219
277
  <!-- Tag sidebar (192px) -->
220
278
  <aside class="w-48 min-w-48 shrink-0 border-r bg-card flex flex-col overflow-hidden" aria-label="Filtry tagów">
221
- {#if tagsQuery.ready && allFilesQuery.ready && tagsQuery.current && allFilesQuery.current}
279
+ {#if tagsQuery.ready && tagsQuery.current}
222
280
  <TagSidebar
223
281
  tags={tagsQuery.current}
224
- files={allFilesQuery.current}
282
+ tagCounts={tagCountsQuery.ready ? tagCountsQuery.current : []}
283
+ totalCount={totalCountQuery.ready ? totalCountQuery.current : 0}
284
+ untaggedCount={untaggedCountQuery.ready ? untaggedCountQuery.current : 0}
225
285
  activeFilter={activeTagFilter}
226
286
  onFilterChange={(f) => (activeTagFilter = f)}
227
287
  onCreateTag={handleCreateTag}
@@ -250,9 +310,10 @@
250
310
  </Toggle>
251
311
  <div class="flex-1"></div>
252
312
  <FileUpload
253
- onUpload={() => { filesQuery.refresh(); allFilesQuery.refresh(); }}
313
+ onUpload={() => refreshAll()}
254
314
  {accept}
255
315
  bind:dropZoneRef
316
+ tagIds={activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined}
256
317
  />
257
318
  </div>
258
319
 
@@ -261,14 +322,14 @@
261
322
 
262
323
  <!-- File grid -->
263
324
  <div class="flex-1 overflow-y-auto px-5 pt-4 pb-24 scrollbar-thin" role="listbox" aria-multiselectable="true" aria-label="Siatka plików">
264
- <div class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3" class:opacity-60={filesQuery.loading} style="transition: opacity 200ms">
265
- {#if !filesQuery.ready}
325
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3" class:opacity-60={filesQuery.loading && page === 0} style="transition: opacity 200ms">
326
+ {#if !filesQuery.ready && page === 0}
266
327
  {#each Array(8) as _}
267
328
  <Skeleton class="block h-[168px] rounded-xl" />
268
329
  {/each}
269
- {:else if filesQuery.current}
330
+ {:else}
270
331
  <FilesList
271
- files={filesQuery.current}
332
+ files={loadedFiles}
272
333
  selected={selectedFileIds.length > 0 ? selectedFileIds : (selected ?? '')}
273
334
  onSelect={handleFileSelect}
274
335
  onRangeSelect={handleRangeSelect}
@@ -276,15 +337,27 @@
276
337
  />
277
338
  {/if}
278
339
  </div>
340
+ {#if hasMore}
341
+ <div class="flex justify-center py-6">
342
+ <button
343
+ type="button"
344
+ class="inline-flex items-center gap-2 rounded-lg border border-border px-5 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
345
+ disabled={filesQuery.loading}
346
+ onclick={() => page++}
347
+ >
348
+ {lang[interfaceLanguage.current].loadMore} ({loadedFiles.length} / {totalCount})
349
+ </button>
350
+ </div>
351
+ {/if}
279
352
  </div>
280
353
  </section>
281
354
 
282
355
  <!-- Detail panel (384px) -->
283
356
  <aside class="w-96 min-w-96 shrink-0 border-l bg-card flex flex-col overflow-hidden" aria-label="Szczegóły pliku">
284
357
  {#if selectionMode}
285
- {#if filesQuery.ready && filesQuery.current && tagsQuery.ready && tagsQuery.current}
358
+ {#if tagsQuery.ready && tagsQuery.current}
286
359
  <MultiFileSummary
287
- files={filesQuery.current.filter((f) => selectedFileIds.includes(f.id))}
360
+ files={loadedFiles.filter((f) => selectedFileIds.includes(f.id))}
288
361
  allTags={tagsQuery.current}
289
362
  onBulkTag={handleBulkTag}
290
363
  onBulkDelete={handleBulkDelete}
@@ -299,8 +372,7 @@
299
372
  onDelete={deleteFileCommand}
300
373
  onReplace={(updated) => {
301
374
  currentFile = updated;
302
- filesQuery.refresh();
303
- allFilesQuery.refresh();
375
+ refreshAll();
304
376
  }}
305
377
  {onTagUpdate}
306
378
  {onNameUpdate}
@@ -323,15 +395,15 @@
323
395
  </div>
324
396
 
325
397
  <!-- Bulk action bar -->
326
- {#if selectedFileIds.length > 0 && tagsQuery.ready && tagsQuery.current && allFilesQuery.ready && allFilesQuery.current}
398
+ {#if selectedFileIds.length > 0 && tagsQuery.ready && tagsQuery.current}
327
399
  <BulkActionBar
328
400
  selectedCount={selectedFileIds.length}
329
- totalCount={allFilesQuery.current.length}
401
+ totalCount={totalCount}
330
402
  tags={tagsQuery.current}
331
403
  onBulkTag={handleBulkTag}
332
404
  onBulkDelete={handleBulkDelete}
333
405
  onClear={exitSelectionMode}
334
- onSelectAll={() => { selectedFileIds = allFilesQuery.current!.map((f) => f.id); }}
406
+ onSelectAll={() => { selectedFileIds = loadedFiles.map((f) => f.id); }}
335
407
  onCreateTag={handleCreateTag}
336
408
  onUpdateTagColor={async (id, color) => {
337
409
  const tag = tagsQuery.current?.find((t) => t.id === id);
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import FileUpload from './file-upload.svelte';
3
- import { onMount } from 'svelte';
3
+ import { onMount, untrack } from 'svelte';
4
4
  import type { MediaFile, MediaTag } from '../../../types/media.js';
5
5
  import { getRemotes } from '../../context/remotes.js';
6
6
  import Button from '../../../components/ui/button/button.svelte';
@@ -29,6 +29,8 @@
29
29
  cancel: string;
30
30
  confirm: string;
31
31
  selectedCount: string;
32
+ clickToSelect: string;
33
+ selectedFile: string;
32
34
  }
33
35
  > = {
34
36
  pl: {
@@ -39,7 +41,9 @@
39
41
  selectionResetLabel: 'Resetuj wybór',
40
42
  cancel: 'Anuluj',
41
43
  confirm: 'Potwierdź',
42
- selectedCount: 'Wybrano'
44
+ selectedCount: 'Wybrano',
45
+ clickToSelect: '{lang[interfaceLanguage.current].clickToSelect}',
46
+ selectedFile: 'Wybrany plik:'
43
47
  },
44
48
  en: {
45
49
  fileDeletedToast: 'File has been deleted',
@@ -49,7 +53,9 @@
49
53
  selectionResetLabel: 'Reset selection',
50
54
  cancel: 'Cancel',
51
55
  confirm: 'Confirm',
52
- selectedCount: 'Selected'
56
+ selectedCount: 'Selected',
57
+ clickToSelect: 'Click a file to select it',
58
+ selectedFile: 'Selected file:'
53
59
  }
54
60
  };
55
61
 
@@ -66,10 +72,14 @@
66
72
 
67
73
  let { selected = $bindable([]), multiple = false, accept, onConfirm, onCancel }: Props = $props();
68
74
 
75
+ const PAGE_SIZE = 48;
76
+
69
77
  let currentFile: MediaFile | null = $state(null);
70
78
  let activeTagFilter = $state<string | null>(null);
71
79
  let searchQuery = $state('');
72
80
  let dropZoneRef = $state<HTMLElement | null>(null);
81
+ let page = $state(0);
82
+ let loadedFiles = $state<MediaFile[]>([]);
73
83
 
74
84
  // Staged selection - changes are only committed on confirm
75
85
  let stagedSelection = $state<string[] | string>(
@@ -86,17 +96,66 @@
86
96
  onCancel?.();
87
97
  }
88
98
 
99
+ const filterData = $derived({
100
+ tagIds: activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined,
101
+ untagged: activeTagFilter === 'untagged' ? true : undefined,
102
+ mimeTypes: accept?.split(','),
103
+ search: searchQuery || undefined
104
+ });
105
+
106
+ $effect(() => {
107
+ void activeTagFilter;
108
+ void searchQuery;
109
+ void accept;
110
+ untrack(() => {
111
+ page = 0;
112
+ loadedFiles = [];
113
+ });
114
+ });
115
+
89
116
  const filesQuery = $derived(
90
117
  remotes.getMediaFiles({
91
118
  data: {
92
- tagIds: activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined,
93
- untagged: activeTagFilter === 'untagged' ? true : undefined,
94
- mimeTypes: accept?.split(','),
95
- search: searchQuery || undefined
119
+ ...filterData,
120
+ limit: PAGE_SIZE,
121
+ offset: page * PAGE_SIZE
122
+ }
123
+ })
124
+ );
125
+
126
+ const countQuery = $derived(
127
+ remotes.countMediaFiles({
128
+ data: {
129
+ tagIds: filterData.tagIds,
130
+ untagged: filterData.untagged,
131
+ mimeTypes: filterData.mimeTypes,
132
+ search: filterData.search
96
133
  }
97
134
  })
98
135
  );
99
136
 
137
+ const tagCountsQuery = $derived(remotes.getMediaTagsWithCounts());
138
+ const totalCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(',') } }));
139
+ const untaggedCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(','), untagged: true } }));
140
+
141
+ $effect(() => {
142
+ const ready = filesQuery.ready;
143
+ const current = filesQuery.current;
144
+ if (ready && current) {
145
+ untrack(() => {
146
+ if (page === 0) {
147
+ loadedFiles = current;
148
+ } else {
149
+ const existingIds = new Set(loadedFiles.map((f) => f.id));
150
+ loadedFiles = [...loadedFiles, ...current.filter((f) => !existingIds.has(f.id))];
151
+ }
152
+ });
153
+ }
154
+ });
155
+
156
+ const totalFilteredCount = $derived(countQuery.ready ? (countQuery.current as number) : 0);
157
+ const hasMore = $derived(loadedFiles.length < totalFilteredCount);
158
+
100
159
  let tagsQuery = $derived(remotes.getMediaTags());
101
160
 
102
161
  onMount(() => {
@@ -115,7 +174,9 @@
115
174
  {#if tagsQuery.ready && tagsQuery.current}
116
175
  <TagSidebar
117
176
  tags={tagsQuery.current}
118
- files={[]}
177
+ tagCounts={tagCountsQuery.ready ? tagCountsQuery.current : []}
178
+ totalCount={totalCountQuery.ready ? totalCountQuery.current : 0}
179
+ untaggedCount={untaggedCountQuery.ready ? untaggedCountQuery.current : 0}
119
180
  activeFilter={activeTagFilter}
120
181
  onFilterChange={(f) => (activeTagFilter = f)}
121
182
  onCreateTag={async (name, color) => {
@@ -141,22 +202,23 @@
141
202
  <MediaSearch bind:value={searchQuery} />
142
203
  <MediaSort />
143
204
  <FileUpload
144
- onUpload={() => filesQuery.refresh()}
205
+ onUpload={() => { page = 0; loadedFiles = []; filesQuery.refresh(); countQuery.refresh(); tagCountsQuery.refresh(); totalCountQuery.refresh(); untaggedCountQuery.refresh(); }}
145
206
  {accept}
146
207
  bind:dropZoneRef
208
+ tagIds={activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined}
147
209
  />
148
210
  </div>
149
211
 
150
212
  <!-- Grid z plikami -->
151
213
  <div class="flex-1 overflow-y-auto pr-2">
152
- <div class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3 p-4" class:opacity-60={filesQuery.loading} style="transition: opacity 200ms">
153
- {#if !filesQuery.ready}
214
+ <div class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3 p-4" class:opacity-60={filesQuery.loading && page === 0} style="transition: opacity 200ms">
215
+ {#if !filesQuery.ready && page === 0}
154
216
  {#each Array(8) as _}
155
217
  <Skeleton class="block aspect-square rounded-2xl" />
156
218
  {/each}
157
- {:else if filesQuery.current}
219
+ {:else}
158
220
  <FilesList
159
- files={filesQuery.current}
221
+ files={loadedFiles}
160
222
  selected={stagedSelection}
161
223
  onSelect={(file) => {
162
224
  currentFile = file;
@@ -171,6 +233,18 @@
171
233
  />
172
234
  {/if}
173
235
  </div>
236
+ {#if hasMore}
237
+ <div class="flex justify-center py-4">
238
+ <button
239
+ type="button"
240
+ class="inline-flex items-center gap-2 rounded-lg border border-border px-5 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
241
+ disabled={filesQuery.loading}
242
+ onclick={() => page++}
243
+ >
244
+ {loadedFiles.length} / {totalFilteredCount}
245
+ </button>
246
+ </div>
247
+ {/if}
174
248
  </div>
175
249
  </div>
176
250
 
@@ -215,15 +289,15 @@
215
289
  {/each}
216
290
 
217
291
  {#if stagedSelection.length === 0}
218
- <p class="text-sm text-muted-foreground py-8 text-center">Kliknij na plik aby go wybrać</p>
292
+ <p class="text-sm text-muted-foreground py-8 text-center">{lang[interfaceLanguage.current].clickToSelect}</p>
219
293
  {/if}
220
294
  {:else}
221
- <p class="text-sm font-medium text-muted-foreground">Wybrany plik:</p>
295
+ <p class="text-sm font-medium text-muted-foreground">{lang[interfaceLanguage.current].selectedFile}</p>
222
296
 
223
297
  {#if stagedSelection}
224
298
  <FilePreview fileId={stagedSelection} />
225
299
  {:else}
226
- <p class="text-sm text-muted-foreground py-8 text-center">Kliknij na plik aby go wybrać</p>
300
+ <p class="text-sm text-muted-foreground py-8 text-center">{lang[interfaceLanguage.current].clickToSelect}</p>
227
301
  {/if}
228
302
  {/if}
229
303
  </div>
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
3
3
  import type { InterfaceLanguage } from '../../../types/languages.js';
4
- import type { MediaFile, MediaTag } from '../../../types/media.js';
4
+ import type { MediaTag } from '../../../types/media.js';
5
5
  import Input from '../../../components/ui/input/input.svelte';
6
6
  import * as Popover from '../../../components/ui/popover/index.js';
7
7
  import * as AlertDialog from '../../../components/ui/alert-dialog/index.js';
@@ -65,7 +65,9 @@
65
65
 
66
66
  type Props = {
67
67
  tags: MediaTag[];
68
- files: MediaFile[];
68
+ tagCounts?: { tag: MediaTag; count: number }[];
69
+ totalCount?: number;
70
+ untaggedCount?: number;
69
71
  activeFilter: string | null;
70
72
  onFilterChange: (filter: string | null) => void;
71
73
  onCreateTag: (name: string, color: string) => Promise<void>;
@@ -75,7 +77,9 @@
75
77
 
76
78
  let {
77
79
  tags,
78
- files,
80
+ tagCounts = [],
81
+ totalCount = 0,
82
+ untaggedCount: untaggedCountProp = 0,
79
83
  activeFilter,
80
84
  onFilterChange,
81
85
  onCreateTag,
@@ -92,10 +96,10 @@
92
96
  let menuOpenTagId = $state<string | null>(null);
93
97
 
94
98
  function getTagFileCount(tagId: string): number {
95
- return files.filter((f) => f.tags.some((t) => t.id === tagId)).length;
99
+ return tagCounts.find((tc) => tc.tag.id === tagId)?.count ?? 0;
96
100
  }
97
101
 
98
- const untaggedCount = $derived(files.filter((f) => f.tags.length === 0).length);
102
+ const untaggedCount = $derived(untaggedCountProp);
99
103
 
100
104
  function getNextColor(): string {
101
105
  const usedColors = tags.map((t) => t.color);
@@ -152,7 +156,7 @@
152
156
  <Tag class="h-4 w-4" />
153
157
  </span>
154
158
  <span class="flex-1 truncate">{lang[interfaceLanguage.current].all}</span>
155
- <span class="text-[11px] font-semibold tabular-nums {activeFilter === null ? 'text-primary' : 'text-text-light'}">{files.length}</span>
159
+ <span class="text-[11px] font-semibold tabular-nums {activeFilter === null ? 'text-primary' : 'text-text-light'}">{totalCount}</span>
156
160
  </button>
157
161
  </li>
158
162
 
@@ -1,7 +1,12 @@
1
- import type { MediaFile, MediaTag } from '../../../types/media.js';
1
+ import type { MediaTag } from '../../../types/media.js';
2
2
  type Props = {
3
3
  tags: MediaTag[];
4
- files: MediaFile[];
4
+ tagCounts?: {
5
+ tag: MediaTag;
6
+ count: number;
7
+ }[];
8
+ totalCount?: number;
9
+ untaggedCount?: number;
5
10
  activeFilter: string | null;
6
11
  onFilterChange: (filter: string | null) => void;
7
12
  onCreateTag: (name: string, color: string) => Promise<void>;