includio-cms 0.7.2 → 0.13.0

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 (164) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/ROADMAP.md +40 -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 +19 -6
  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/components/fields/blocks-field.svelte +9 -10
  35. package/dist/admin/components/fields/field-renderer.svelte +4 -8
  36. package/dist/admin/components/fields/object-field.svelte +7 -12
  37. package/dist/admin/components/fields/select-field.svelte +8 -2
  38. package/dist/admin/components/fields/seo-field.svelte +40 -93
  39. package/dist/admin/components/fields/simple-array-field.svelte +5 -5
  40. package/dist/admin/components/fields/text-field-wrapper.svelte +52 -197
  41. package/dist/admin/components/fields/text-field-wrapper.svelte.d.ts +2 -2
  42. package/dist/admin/components/fields/url-field-wrapper.svelte +15 -25
  43. package/dist/admin/components/fields/url-field.svelte +61 -72
  44. package/dist/admin/components/media/file-upload.svelte +5 -1
  45. package/dist/admin/components/media/file-upload.svelte.d.ts +1 -0
  46. package/dist/admin/components/media/media-library.svelte +109 -37
  47. package/dist/admin/components/media/media-selector.svelte +79 -11
  48. package/dist/admin/components/media/tag-sidebar.svelte +10 -6
  49. package/dist/admin/components/media/tag-sidebar.svelte.d.ts +7 -2
  50. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +21 -93
  51. package/dist/admin/components/tiptap/inline-block-node.js +6 -5
  52. package/dist/admin/components/tiptap/link-dialog.svelte +10 -11
  53. package/dist/admin/components/tiptap/slash-command.js +1 -1
  54. package/dist/admin/remote/entry.remote.d.ts +2 -5
  55. package/dist/admin/remote/entry.remote.js +22 -27
  56. package/dist/admin/remote/media.remote.d.ts +15 -0
  57. package/dist/admin/remote/media.remote.js +18 -2
  58. package/dist/admin/remote/preview.remote.js +3 -1
  59. package/dist/admin/utils/entryLabel.js +9 -6
  60. package/dist/admin/utils/translationStatus.js +1 -2
  61. package/dist/cli/scaffold/admin.js +34 -2
  62. package/dist/cms/runtime/api.d.ts +16 -12
  63. package/dist/cms/runtime/api.js +7 -6
  64. package/dist/cms/runtime/remote.js +2 -2
  65. package/dist/cms/runtime/schemas.d.ts +1 -1
  66. package/dist/cms/runtime/schemas.js +1 -1
  67. package/dist/cms/runtime/types.d.ts +118 -112
  68. package/dist/cms/runtime/types.js +0 -12
  69. package/dist/core/cms.d.ts +3 -1
  70. package/dist/core/cms.js +30 -0
  71. package/dist/core/fields/fieldSchemaToTs.js +9 -15
  72. package/dist/core/fields/formFieldSchemaToTs.js +7 -0
  73. package/dist/core/server/entries/operations/create.js +10 -4
  74. package/dist/core/server/entries/operations/get.d.ts +1 -0
  75. package/dist/core/server/entries/operations/get.js +186 -191
  76. package/dist/core/server/entries/operations/update.d.ts +6 -7
  77. package/dist/core/server/entries/operations/update.js +20 -38
  78. package/dist/core/server/fields/populateEntry.js +16 -52
  79. package/dist/core/server/fields/resolveImageFields.js +69 -120
  80. package/dist/core/server/fields/resolveRelationFields.js +30 -51
  81. package/dist/core/server/fields/resolveRichtextLinks.js +46 -100
  82. package/dist/core/server/fields/resolveTypographyOrphans.bench.d.ts +1 -0
  83. package/dist/core/server/fields/resolveTypographyOrphans.bench.js +87 -0
  84. package/dist/core/server/fields/resolveTypographyOrphans.d.ts +3 -0
  85. package/dist/core/server/fields/resolveTypographyOrphans.js +128 -0
  86. package/dist/core/server/fields/resolveUrlFields.js +47 -56
  87. package/dist/core/server/fields/utils/fixOrphans.d.ts +5 -0
  88. package/dist/core/server/fields/utils/fixOrphans.js +12 -0
  89. package/dist/core/server/fields/utils/imageStyles.d.ts +4 -2
  90. package/dist/core/server/fields/utils/imageStyles.js +41 -25
  91. package/dist/core/server/fields/utils/resolveMedia.js +1 -6
  92. package/dist/core/server/forms/submissions/operations/delete.js +26 -2
  93. package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +2 -0
  94. package/dist/core/server/forms/submissions/utils/parseMultipart.js +75 -0
  95. package/dist/core/server/generator/fields.d.ts +6 -0
  96. package/dist/core/server/generator/fields.js +43 -5
  97. package/dist/core/server/generator/formFieldSchemaToString.js +10 -0
  98. package/dist/core/server/generator/formFields.js +1 -0
  99. package/dist/core/server/generator/generator.js +98 -30
  100. package/dist/core/server/media/operations/getFiles.d.ts +5 -0
  101. package/dist/core/server/media/operations/getFiles.js +6 -0
  102. package/dist/core/server/media/operations/uploadPrivateFile.d.ts +4 -0
  103. package/dist/core/server/media/operations/uploadPrivateFile.js +8 -0
  104. package/dist/core/server/media/styles/operations/batchGenerateStyles.d.ts +16 -0
  105. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +144 -0
  106. package/dist/db-postgres/index.js +303 -37
  107. package/dist/db-postgres/schema/entry.d.ts +0 -94
  108. package/dist/db-postgres/schema/entry.js +0 -6
  109. package/dist/db-postgres/schema/entryVersion.d.ts +17 -0
  110. package/dist/db-postgres/schema/entryVersion.js +1 -0
  111. package/dist/entity/index.d.ts +9 -4
  112. package/dist/entity/index.js +24 -24
  113. package/dist/files-local/index.js +43 -0
  114. package/dist/paraglide/messages/_index.d.ts +36 -3
  115. package/dist/paraglide/messages/_index.js +71 -3
  116. package/dist/paraglide/messages/en.d.ts +5 -0
  117. package/dist/paraglide/messages/en.js +14 -0
  118. package/dist/paraglide/messages/pl.d.ts +5 -0
  119. package/dist/paraglide/messages/pl.js +14 -0
  120. package/dist/sveltekit/components/preview.svelte +2 -326
  121. package/dist/sveltekit/components/preview.svelte.d.ts +5 -16
  122. package/dist/sveltekit/server/index.d.ts +2 -1
  123. package/dist/sveltekit/server/index.js +2 -1
  124. package/dist/sveltekit/server/preview.js +4 -7
  125. package/dist/types/adapters/db.d.ts +15 -1
  126. package/dist/types/adapters/files.d.ts +6 -0
  127. package/dist/types/cms.d.ts +5 -0
  128. package/dist/types/entries.d.ts +54 -18
  129. package/dist/types/fields.d.ts +14 -24
  130. package/dist/types/formFields.d.ts +7 -2
  131. package/dist/types/index.d.ts +2 -2
  132. package/dist/types/structured-content.d.ts +5 -0
  133. package/dist/updates/0.10.0/index.d.ts +2 -0
  134. package/dist/updates/0.10.0/index.js +15 -0
  135. package/dist/updates/0.11.0/index.d.ts +2 -0
  136. package/dist/updates/0.11.0/index.js +12 -0
  137. package/dist/updates/0.12.0/index.d.ts +2 -0
  138. package/dist/updates/0.12.0/index.js +12 -0
  139. package/dist/updates/0.13.0/index.d.ts +2 -0
  140. package/dist/updates/0.13.0/index.js +10 -0
  141. package/dist/updates/0.7.3/index.d.ts +2 -0
  142. package/dist/updates/0.7.3/index.js +10 -0
  143. package/dist/updates/0.8.0/index.d.ts +2 -0
  144. package/dist/updates/0.8.0/index.js +18 -0
  145. package/dist/updates/0.8.0/migrate.d.ts +2 -0
  146. package/dist/updates/0.8.0/migrate.js +101 -0
  147. package/dist/updates/0.9.0/index.d.ts +2 -0
  148. package/dist/updates/0.9.0/index.js +38 -0
  149. package/dist/updates/index.js +8 -1
  150. package/package.json +7 -6
  151. package/dist/admin/components/fields/image-field.svelte +0 -198
  152. package/dist/admin/components/fields/image-field.svelte.d.ts +0 -8
  153. package/dist/admin/components/fields/richtext-field.svelte +0 -13
  154. package/dist/admin/components/fields/richtext-field.svelte.d.ts +0 -8
  155. package/dist/admin/components/tiptap.svelte +0 -11
  156. package/dist/admin/components/tiptap.svelte.d.ts +0 -6
  157. package/dist/core/server/entries/utils/getEntryTranslation.d.ts +0 -1
  158. package/dist/core/server/entries/utils/getEntryTranslation.js +0 -18
  159. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  160. package/dist/paraglide/messages/hello_world.js +0 -33
  161. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  162. package/dist/paraglide/messages/login_hello.js +0 -34
  163. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  164. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -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>;
@@ -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';
@@ -66,10 +66,14 @@
66
66
 
67
67
  let { selected = $bindable([]), multiple = false, accept, onConfirm, onCancel }: Props = $props();
68
68
 
69
+ const PAGE_SIZE = 48;
70
+
69
71
  let currentFile: MediaFile | null = $state(null);
70
72
  let activeTagFilter = $state<string | null>(null);
71
73
  let searchQuery = $state('');
72
74
  let dropZoneRef = $state<HTMLElement | null>(null);
75
+ let page = $state(0);
76
+ let loadedFiles = $state<MediaFile[]>([]);
73
77
 
74
78
  // Staged selection - changes are only committed on confirm
75
79
  let stagedSelection = $state<string[] | string>(
@@ -86,17 +90,66 @@
86
90
  onCancel?.();
87
91
  }
88
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
+ $effect(() => {
101
+ void activeTagFilter;
102
+ void searchQuery;
103
+ void accept;
104
+ untrack(() => {
105
+ page = 0;
106
+ loadedFiles = [];
107
+ });
108
+ });
109
+
89
110
  const filesQuery = $derived(
90
111
  remotes.getMediaFiles({
91
112
  data: {
92
- tagIds: activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined,
93
- untagged: activeTagFilter === 'untagged' ? true : undefined,
94
- mimeTypes: accept?.split(','),
95
- search: searchQuery || undefined
113
+ ...filterData,
114
+ limit: PAGE_SIZE,
115
+ offset: page * PAGE_SIZE
116
+ }
117
+ })
118
+ );
119
+
120
+ const countQuery = $derived(
121
+ remotes.countMediaFiles({
122
+ data: {
123
+ tagIds: filterData.tagIds,
124
+ untagged: filterData.untagged,
125
+ mimeTypes: filterData.mimeTypes,
126
+ search: filterData.search
96
127
  }
97
128
  })
98
129
  );
99
130
 
131
+ const tagCountsQuery = $derived(remotes.getMediaTagsWithCounts());
132
+ const totalCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(',') } }));
133
+ const untaggedCountQuery = $derived(remotes.countMediaFiles({ data: { mimeTypes: accept?.split(','), untagged: true } }));
134
+
135
+ $effect(() => {
136
+ const ready = filesQuery.ready;
137
+ const current = filesQuery.current;
138
+ if (ready && current) {
139
+ untrack(() => {
140
+ if (page === 0) {
141
+ loadedFiles = current;
142
+ } else {
143
+ const existingIds = new Set(loadedFiles.map((f) => f.id));
144
+ loadedFiles = [...loadedFiles, ...current.filter((f) => !existingIds.has(f.id))];
145
+ }
146
+ });
147
+ }
148
+ });
149
+
150
+ const totalFilteredCount = $derived(countQuery.ready ? (countQuery.current as number) : 0);
151
+ const hasMore = $derived(loadedFiles.length < totalFilteredCount);
152
+
100
153
  let tagsQuery = $derived(remotes.getMediaTags());
101
154
 
102
155
  onMount(() => {
@@ -115,7 +168,9 @@
115
168
  {#if tagsQuery.ready && tagsQuery.current}
116
169
  <TagSidebar
117
170
  tags={tagsQuery.current}
118
- files={[]}
171
+ tagCounts={tagCountsQuery.ready ? tagCountsQuery.current : []}
172
+ totalCount={totalCountQuery.ready ? totalCountQuery.current : 0}
173
+ untaggedCount={untaggedCountQuery.ready ? untaggedCountQuery.current : 0}
119
174
  activeFilter={activeTagFilter}
120
175
  onFilterChange={(f) => (activeTagFilter = f)}
121
176
  onCreateTag={async (name, color) => {
@@ -141,22 +196,23 @@
141
196
  <MediaSearch bind:value={searchQuery} />
142
197
  <MediaSort />
143
198
  <FileUpload
144
- onUpload={() => filesQuery.refresh()}
199
+ onUpload={() => { page = 0; loadedFiles = []; filesQuery.refresh(); countQuery.refresh(); tagCountsQuery.refresh(); totalCountQuery.refresh(); untaggedCountQuery.refresh(); }}
145
200
  {accept}
146
201
  bind:dropZoneRef
202
+ tagIds={activeTagFilter && activeTagFilter !== 'untagged' ? [activeTagFilter] : undefined}
147
203
  />
148
204
  </div>
149
205
 
150
206
  <!-- Grid z plikami -->
151
207
  <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}
208
+ <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">
209
+ {#if !filesQuery.ready && page === 0}
154
210
  {#each Array(8) as _}
155
211
  <Skeleton class="block aspect-square rounded-2xl" />
156
212
  {/each}
157
- {:else if filesQuery.current}
213
+ {:else}
158
214
  <FilesList
159
- files={filesQuery.current}
215
+ files={loadedFiles}
160
216
  selected={stagedSelection}
161
217
  onSelect={(file) => {
162
218
  currentFile = file;
@@ -171,6 +227,18 @@
171
227
  />
172
228
  {/if}
173
229
  </div>
230
+ {#if hasMore}
231
+ <div class="flex justify-center py-4">
232
+ <button
233
+ type="button"
234
+ 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"
235
+ disabled={filesQuery.loading}
236
+ onclick={() => page++}
237
+ >
238
+ {loadedFiles.length} / {totalFilteredCount}
239
+ </button>
240
+ </div>
241
+ {/if}
174
242
  </div>
175
243
  </div>
176
244
 
@@ -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>;