includio-cms 0.0.69 → 0.1.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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/ROADMAP.md +24 -5
  3. package/dist/admin/client/account/lang.d.ts +0 -5
  4. package/dist/admin/client/account/lang.js +2 -12
  5. package/dist/admin/client/account/preferences-section.svelte +27 -24
  6. package/dist/admin/client/account/profile-section.svelte +11 -15
  7. package/dist/admin/client/account/security-section.svelte +0 -11
  8. package/dist/admin/client/account/sessions-section.svelte +2 -1
  9. package/dist/admin/client/collection/collection-entries.svelte +168 -43
  10. package/dist/admin/client/collection/collection-view.svelte.d.ts +5 -0
  11. package/dist/admin/client/collection/collection-view.svelte.js +22 -5
  12. package/dist/admin/client/collection/collection.svelte +2 -2
  13. package/dist/admin/client/collection/data-table.svelte +15 -3
  14. package/dist/admin/client/collection/data-table.svelte.d.ts +3 -0
  15. package/dist/admin/client/entry/entry.svelte +11 -7
  16. package/dist/admin/client/entry/header/status-badge.svelte +6 -3
  17. package/dist/admin/client/entry/header/version-history-sheet.svelte +6 -3
  18. package/dist/admin/client/form/form-submissions.svelte +3 -26
  19. package/dist/admin/components/dashboard/form-submissions-widget.svelte +2 -12
  20. package/dist/admin/components/dashboard/recent-activity.svelte +2 -12
  21. package/dist/admin/components/layout/header-actions.svelte +4 -3
  22. package/dist/admin/components/media/media-library.svelte +17 -6
  23. package/dist/admin/remote/entry.remote.d.ts +10 -1
  24. package/dist/admin/remote/entry.remote.js +16 -4
  25. package/dist/admin/state/interface-language.svelte.d.ts +4 -7
  26. package/dist/admin/state/interface-language.svelte.js +19 -18
  27. package/dist/admin/utils/formatDate.d.ts +1 -0
  28. package/dist/admin/utils/formatDate.js +5 -1
  29. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  30. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  31. package/dist/core/server/entries/operations/get.d.ts +2 -0
  32. package/dist/core/server/entries/operations/get.js +6 -0
  33. package/dist/db-postgres/index.js +26 -1
  34. package/dist/sveltekit/components/hybrid-context.d.ts +4 -0
  35. package/dist/sveltekit/components/hybrid-context.js +9 -0
  36. package/dist/sveltekit/components/hybrid-target.svelte +4 -1
  37. package/dist/sveltekit/components/image.svelte +5 -2
  38. package/dist/sveltekit/components/preview.svelte +3 -0
  39. package/dist/sveltekit/components/video.svelte +4 -1
  40. package/dist/sveltekit/index.d.ts +1 -0
  41. package/dist/sveltekit/index.js +1 -0
  42. package/dist/types/adapters/db.d.ts +3 -1
  43. package/dist/types/entries.d.ts +10 -2
  44. package/dist/types/languages.d.ts +2 -1
  45. package/dist/updates/0.1.0/index.d.ts +2 -0
  46. package/dist/updates/0.1.0/index.js +25 -0
  47. package/dist/updates/index.js +2 -1
  48. package/package.json +1 -1
  49. package/dist/core/server/utils/sanitizeRichText.d.ts +0 -1
  50. package/dist/core/server/utils/sanitizeRichText.js +0 -67
package/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.1.0 — 2026-02-17
7
+
8
+ Stabilization — pagination, language switcher, and more
9
+
10
+ ### Added
11
+ - Server-side pagination for collection entries (limit/offset/orderBy)
12
+ - Persistent page index and active tab across navigation
13
+ - countEntries adapter method for total count queries
14
+ - Automatic fallback to client-side pagination for search and name sorting
15
+
16
+ ### Fixed
17
+ - Language switcher now reactive globally — all UI updates instantly without reload
18
+ - Removed hardcoded en/pl — locale list derived from Paraglide config
19
+ - Browser language auto-detected as default interface language
20
+ - Deduplicated formatRelativeDate across dashboard and form components
21
+ - Account profile: fixed name input not responding to typing
22
+ - Account preferences: added aria-pressed to theme/language toggles
23
+ - Removed placeholder 2FA stub from security section
24
+ - Media tag counts now display correct values — TagSidebar and BulkActionBar receive all files
25
+ - Hybrid target: data-hybrid-path no longer rendered on public pages — only in preview iframe
26
+
27
+ ### Breaking
28
+ - getRawEntries remote now returns { entries: RawEntry[], total: number } instead of RawEntry[]
29
+
6
30
  ## 0.0.69 — 2026-02-17
7
31
 
8
32
  DnD array reordering, nav-search batch fetch
package/ROADMAP.md CHANGED
@@ -14,11 +14,11 @@
14
14
 
15
15
  ## 0.1.0 — Stabilization
16
16
 
17
- - [ ] `[fix]` `[P0]` Collection table pagination — server-side, persistent page state, archived tab <!-- files: src/lib/admin/client/collection/collection-entries.svelte, src/lib/admin/client/collection/table-pagination.svelte -->
18
- - [ ] `[fix]` `[P0]` Language switcher — reactive globally without reload, remove hardcoded en/pl <!-- files: src/lib/admin/state/interface-language.svelte.ts, src/lib/admin/components/layout/header-actions.svelte -->
19
- - [ ] `[fix]` `[P0]` User account section — email change, aria-pressed on prefs, avatar upload, clean up 2FA stub <!-- files: src/lib/admin/client/account/ -->
20
- - [ ] `[fix]` `[P0]` Media tag counts always 0 — pass actual files to TagSidebar <!-- files: src/lib/admin/components/media/media-library.svelte, src/lib/admin/components/media/tag-sidebar.svelte -->
21
- - [ ] `[fix]` `[P0]` Hybrid target — don't render `data-hybrid-path` for non-logged-in users <!-- files: src/lib/sveltekit/components/hybrid-target.svelte -->
17
+ - [x] `[fix]` `[P0]` Collection table pagination — server-side, persistent page state, archived tab <!-- files: src/lib/admin/client/collection/collection-entries.svelte, src/lib/admin/client/collection/table-pagination.svelte -->
18
+ - [x] `[fix]` `[P0]` Language switcher — reactive globally without reload, remove hardcoded en/pl <!-- files: src/lib/admin/state/interface-language.svelte.ts, src/lib/admin/components/layout/header-actions.svelte -->
19
+ - [x] `[fix]` `[P0]` User account section — fix name input, aria-pressed on prefs, clean up 2FA stub (email change deferred to 0.1.2, avatar upload skipped) <!-- files: src/lib/admin/client/account/ -->
20
+ - [x] `[fix]` `[P0]` Media tag counts always 0 — pass actual files to TagSidebar <!-- files: src/lib/admin/components/media/media-library.svelte, src/lib/admin/components/media/tag-sidebar.svelte -->
21
+ - [x] `[fix]` `[P0]` Hybrid target — don't render `data-hybrid-path` for non-logged-in users <!-- files: src/lib/sveltekit/components/hybrid-target.svelte -->
22
22
 
23
23
  ## 0.1.1 — Input integrity
24
24
 
@@ -27,6 +27,25 @@
27
27
  - [ ] `[feature]` `[P1]` Array field fixed length — fixed item count, no add/remove, reorder only
28
28
  - [ ] `[feature]` `[P1]` Field constraint info display — show constraints before validation error (WCAG/ATAG)
29
29
 
30
+ ## 0.1.2 — User management & RBAC
31
+
32
+ ### Phase 1 — Core
33
+
34
+ - [ ] `[feature]` `[P0]` RBAC middleware — `requireRole()`, role check w `requireAuth()` <!-- files: src/lib/admin/remote/middleware/auth.ts -->
35
+ - [ ] `[feature]` `[P0]` Admin users page — list, search, pagination via `authClient.admin.listUsers` <!-- files: src/lib/admin/client/users/ -->
36
+ - [ ] `[feature]` `[P0]` Create user — dialog z email/password/name/role via `authClient.admin.createUser` <!-- files: src/lib/admin/client/users/create-user-dialog.svelte -->
37
+ - [ ] `[feature]` `[P0]` Edit user — name, email, role via `adminUpdateUser` + `setRole` <!-- files: src/lib/admin/client/users/edit-user-dialog.svelte -->
38
+ - [ ] `[feature]` `[P0]` Delete user — confirmation dialog via `removeUser` <!-- files: src/lib/admin/client/users/ -->
39
+ - [ ] `[feature]` `[P0]` Route/sidebar gating — ukryj Users nav + chroń `/admin/users` dla non-admin <!-- files: src/lib/admin/components/layout/nav-main.svelte, src/lib/sveltekit/server/handle.ts -->
40
+ - [ ] `[feature]` `[P0]` First user bootstrap — pierwszy utworzony user auto-gets role `admin` <!-- files: src/lib/server/auth.ts -->
41
+
42
+ ### Phase 2 — Extended
43
+
44
+ - [ ] `[feature]` `[P1]` Ban/unban UI — reason + expiry, `banUser`/`unbanUser` <!-- files: src/lib/admin/client/users/ban-user-dialog.svelte -->
45
+ - [ ] `[feature]` `[P1]` Admin session mgmt — list/revoke sesji innych userów <!-- files: src/lib/admin/client/users/user-sessions-sheet.svelte -->
46
+ - [ ] `[feature]` `[P1]` Impersonation UI — impersonate/stop bar <!-- files: src/lib/admin/client/users/impersonation-bar.svelte -->
47
+ - [ ] `[feature]` `[P2]` Email invitation system — invite link generation (custom, nie w better-auth) <!-- files: src/lib/core/server/auth/invite.ts -->
48
+
30
49
  ## 0.2.0 — Plugin system
31
50
 
32
51
  - [ ] `[feature]` `[P0]` Wire plugin hooks into CRUD operations (before/afterCreate, Update, Delete) <!-- files: src/lib/types/plugins.ts, src/lib/core/server/entries/operations/ -->
@@ -23,9 +23,6 @@ export declare const accountLang: Record<string, {
23
23
  confirmPassword: string;
24
24
  updatePassword: string;
25
25
  passwordUpdated: string;
26
- twoFactor: string;
27
- twoFactorDesc: string;
28
- comingSoon: string;
29
26
  deleteAccount: string;
30
27
  deleteAccountDesc: string;
31
28
  deleteAccountButton: string;
@@ -42,8 +39,6 @@ export declare const accountLang: Record<string, {
42
39
  themeDark: string;
43
40
  themeSystem: string;
44
41
  language: string;
45
- languageEn: string;
46
- languagePl: string;
47
42
  };
48
43
  sessions: {
49
44
  title: string;
@@ -24,9 +24,6 @@ export const accountLang = {
24
24
  confirmPassword: 'Potwierdź hasło',
25
25
  updatePassword: 'Zmień hasło',
26
26
  passwordUpdated: 'Hasło zostało zmienione',
27
- twoFactor: 'Uwierzytelnianie dwuskładnikowe',
28
- twoFactorDesc: 'Dodatkowa warstwa zabezpieczeń dla Twojego konta',
29
- comingSoon: 'Wkrótce',
30
27
  deleteAccount: 'Usuń konto',
31
28
  deleteAccountDesc: 'Trwale usuń swoje konto i wszystkie dane',
32
29
  deleteAccountButton: 'Usuń konto',
@@ -42,9 +39,7 @@ export const accountLang = {
42
39
  themeLight: 'Jasny',
43
40
  themeDark: 'Ciemny',
44
41
  themeSystem: 'Systemowy',
45
- language: 'Język',
46
- languageEn: 'Angielski',
47
- languagePl: 'Polski'
42
+ language: 'Język'
48
43
  },
49
44
  sessions: {
50
45
  title: 'Aktywne sesje',
@@ -88,9 +83,6 @@ export const accountLang = {
88
83
  confirmPassword: 'Confirm password',
89
84
  updatePassword: 'Update password',
90
85
  passwordUpdated: 'Password updated',
91
- twoFactor: 'Two-factor authentication',
92
- twoFactorDesc: 'Add an extra layer of security to your account',
93
- comingSoon: 'Coming soon',
94
86
  deleteAccount: 'Delete account',
95
87
  deleteAccountDesc: 'Permanently delete your account and all data',
96
88
  deleteAccountButton: 'Delete account',
@@ -106,9 +98,7 @@ export const accountLang = {
106
98
  themeLight: 'Light',
107
99
  themeDark: 'Dark',
108
100
  themeSystem: 'System',
109
- language: 'Language',
110
- languageEn: 'English',
111
- languagePl: 'Polish'
101
+ language: 'Language'
112
102
  },
113
103
  sessions: {
114
104
  title: 'Active sessions',
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  import * as Card from '../../../components/ui/card/index.js';
3
3
  import { accountLang } from './lang.js';
4
- import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
4
+ import { useInterfaceLanguage, locales } from '../../state/interface-language.svelte.js';
5
5
  import { userPrefersMode, setMode } from 'mode-watcher';
6
6
  import Sun from '@tabler/icons-svelte/icons/sun';
7
7
  import Moon from '@tabler/icons-svelte/icons/moon';
8
8
  import DeviceDesktop from '@tabler/icons-svelte/icons/device-desktop';
9
+ import type { InterfaceLanguage } from '../../../types/languages.js';
9
10
 
10
11
  const interfaceLanguage = useInterfaceLanguage();
11
12
 
@@ -15,9 +16,15 @@
15
16
  setMode(theme);
16
17
  }
17
18
 
18
- function setLanguage(language: 'en' | 'pl') {
19
+ function setLanguage(language: InterfaceLanguage) {
19
20
  interfaceLanguage.current = language;
20
21
  }
22
+
23
+ function getLanguageDisplayName(locale: string): string {
24
+ const name = new Intl.DisplayNames([interfaceLanguage.current], { type: 'language' }).of(locale);
25
+ if (!name) return locale.toUpperCase();
26
+ return name.charAt(0).toUpperCase() + name.slice(1);
27
+ }
21
28
  </script>
22
29
 
23
30
  <div class="space-y-6">
@@ -26,9 +33,10 @@
26
33
  <Card.Title>{lang.preferences.theme}</Card.Title>
27
34
  </Card.Header>
28
35
  <Card.Content>
29
- <div class="flex gap-2">
36
+ <div class="flex gap-2" role="group" aria-label={lang.preferences.theme}>
30
37
  <button
31
38
  type="button"
39
+ aria-pressed={userPrefersMode.current === 'light'}
32
40
  onclick={() => setTheme('light')}
33
41
  class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {userPrefersMode.current ===
34
42
  'light'
@@ -40,6 +48,7 @@
40
48
  </button>
41
49
  <button
42
50
  type="button"
51
+ aria-pressed={userPrefersMode.current === 'dark'}
43
52
  onclick={() => setTheme('dark')}
44
53
  class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {userPrefersMode.current ===
45
54
  'dark'
@@ -51,6 +60,7 @@
51
60
  </button>
52
61
  <button
53
62
  type="button"
63
+ aria-pressed={userPrefersMode.current === 'system'}
54
64
  onclick={() => setTheme('system')}
55
65
  class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {userPrefersMode.current ===
56
66
  'system'
@@ -69,27 +79,20 @@
69
79
  <Card.Title>{lang.preferences.language}</Card.Title>
70
80
  </Card.Header>
71
81
  <Card.Content>
72
- <div class="flex gap-2">
73
- <button
74
- type="button"
75
- onclick={() => setLanguage('en')}
76
- class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {interfaceLanguage.current ===
77
- 'en'
78
- ? 'border-[#2D4A77] bg-[#2D4A77]/10 text-[#2D4A77]'
79
- : 'border-input hover:bg-accent'}"
80
- >
81
- {lang.preferences.languageEn}
82
- </button>
83
- <button
84
- type="button"
85
- onclick={() => setLanguage('pl')}
86
- class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {interfaceLanguage.current ===
87
- 'pl'
88
- ? 'border-[#2D4A77] bg-[#2D4A77]/10 text-[#2D4A77]'
89
- : 'border-input hover:bg-accent'}"
90
- >
91
- {lang.preferences.languagePl}
92
- </button>
82
+ <div class="flex gap-2" role="group" aria-label={lang.preferences.language}>
83
+ {#each locales as locale}
84
+ <button
85
+ type="button"
86
+ aria-pressed={interfaceLanguage.current === locale}
87
+ onclick={() => setLanguage(locale)}
88
+ class="inline-flex h-10 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none {interfaceLanguage.current ===
89
+ locale
90
+ ? 'border-[#2D4A77] bg-[#2D4A77]/10 text-[#2D4A77]'
91
+ : 'border-input hover:bg-accent'}"
92
+ >
93
+ {getLanguageDisplayName(locale)}
94
+ </button>
95
+ {/each}
93
96
  </div>
94
97
  </Card.Content>
95
98
  </Card.Root>
@@ -9,21 +9,27 @@
9
9
  import { profileSchema } from './schema.js';
10
10
  import { accountLang } from './lang.js';
11
11
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
12
+ import { toLocaleCode } from '../../utils/formatDate.js';
12
13
  import { authClient } from '../../auth-client.js';
13
14
  import { toast } from 'svelte-sonner';
15
+ import { untrack } from 'svelte';
14
16
 
15
17
  let session = authClient.useSession();
16
18
  const interfaceLanguage = useInterfaceLanguage();
17
19
 
18
20
  $effect(() => {
19
- if ($session.data?.user.name && $formData.name !== $session.data.user.name) {
20
- $formData.name = $session.data.user.name;
21
+ const sessionName = $session.data?.user.name;
22
+ if (sessionName) {
23
+ untrack(() => {
24
+ $formData.name = sessionName;
25
+ });
21
26
  }
22
27
  });
23
28
 
24
29
  const form = superForm(defaults(zod4(profileSchema)), {
25
30
  validators: zod4Client(profileSchema),
26
31
  SPA: true,
32
+ resetForm: false,
27
33
  onUpdate: async ({ form }) => {
28
34
  if (form.valid) {
29
35
  await authClient.updateUser({
@@ -48,7 +54,7 @@
48
54
  }
49
55
 
50
56
  function formatDate(date: Date | string): string {
51
- return new Date(date).toLocaleDateString(interfaceLanguage.current === 'pl' ? 'pl-PL' : 'en-US', {
57
+ return new Date(date).toLocaleDateString(toLocaleCode(interfaceLanguage.current), {
52
58
  year: 'numeric',
53
59
  month: 'long'
54
60
  });
@@ -79,12 +85,7 @@
79
85
  <Form.Control>
80
86
  {#snippet children({ props })}
81
87
  <Form.Label>{lang.profile.name}</Form.Label>
82
- <Input
83
- {...props}
84
- bind:value={$formData.name}
85
- type="text"
86
- class="mt-1"
87
- />
88
+ <Input {...props} bind:value={$formData.name} type="text" class="mt-1" />
88
89
  {/snippet}
89
90
  </Form.Control>
90
91
  <Form.FieldErrors class="text-sm text-red-500" />
@@ -92,12 +93,7 @@
92
93
 
93
94
  <div class="space-y-2">
94
95
  <label class="text-sm font-medium">{lang.profile.email}</label>
95
- <Input
96
- type="email"
97
- value={$session.data.user.email}
98
- disabled
99
- class="mt-1"
100
- />
96
+ <Input type="email" value={$session.data.user.email} disabled class="mt-1" />
101
97
  </div>
102
98
 
103
99
  <div class="flex items-center gap-4">
@@ -4,7 +4,6 @@
4
4
  import Button from '../../../components/ui/button/button.svelte';
5
5
  import * as Card from '../../../components/ui/card/index.js';
6
6
  import * as AlertDialog from '../../../components/ui/alert-dialog/index.js';
7
- import { Badge } from '../../../components/ui/badge/index.js';
8
7
  import { defaults, superForm } from 'sveltekit-superforms';
9
8
  import { zod4, zod4Client } from 'sveltekit-superforms/adapters';
10
9
  import { passwordSchema, deleteAccountSchema } from './schema.js';
@@ -124,16 +123,6 @@
124
123
  </Card.Content>
125
124
  </Card.Root>
126
125
 
127
- <Card.Root>
128
- <Card.Header>
129
- <Card.Title>{lang.security.twoFactor}</Card.Title>
130
- <Card.Description>{lang.security.twoFactorDesc}</Card.Description>
131
- </Card.Header>
132
- <Card.Content>
133
- <Badge variant="outline">{lang.security.comingSoon}</Badge>
134
- </Card.Content>
135
- </Card.Root>
136
-
137
126
  <Card.Root class="border-destructive/50">
138
127
  <Card.Header>
139
128
  <Card.Title class="text-destructive">{lang.security.deleteAccount}</Card.Title>
@@ -3,6 +3,7 @@
3
3
  import { Badge } from '../../../components/ui/badge/index.js';
4
4
  import { accountLang } from './lang.js';
5
5
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
6
+ import { toLocaleCode } from '../../utils/formatDate.js';
6
7
  import { authClient } from '../../auth-client.js';
7
8
  import { toast } from 'svelte-sonner';
8
9
  import DeviceDesktop from '@tabler/icons-svelte/icons/device-desktop';
@@ -86,7 +87,7 @@
86
87
  }
87
88
 
88
89
  function formatDate(date: Date | string): string {
89
- return new Date(date).toLocaleString(interfaceLanguage.current === 'pl' ? 'pl-PL' : 'en-US', {
90
+ return new Date(date).toLocaleString(toLocaleCode(interfaceLanguage.current), {
90
91
  year: 'numeric',
91
92
  month: 'short',
92
93
  day: 'numeric',
@@ -117,6 +117,46 @@
117
117
  let deleteDialogOpen = $state(false);
118
118
  let pendingDeleteId = $state<string | null>(null);
119
119
 
120
+ // Determine if we need fetch-all fallback (search active or sorting by name)
121
+ const isSortingByName = $derived(
122
+ viewState.sorting.length > 0 && viewState.sorting[0].id === 'name'
123
+ );
124
+ const useServerPagination = $derived(!searchQuery && !isSortingByName);
125
+
126
+ // Build orderBy from sorting state for server-side mode
127
+ const serverOrderBy = $derived.by(() => {
128
+ if (!viewState.sorting.length) return undefined;
129
+ const { id, desc } = viewState.sorting[0];
130
+ if (id === 'createdAt' || id === 'updatedAt') {
131
+ return { column: id as 'createdAt' | 'updatedAt', direction: (desc ? 'desc' : 'asc') as 'asc' | 'desc' };
132
+ }
133
+ return undefined;
134
+ });
135
+
136
+ // Server-side query params
137
+ const activeQueryParams = $derived(
138
+ useServerPagination
139
+ ? {
140
+ slug: collection.slug,
141
+ limit: viewState.pageSize,
142
+ offset: viewState.pageIndex * viewState.pageSize,
143
+ orderBy: serverOrderBy
144
+ }
145
+ : { slug: collection.slug }
146
+ );
147
+
148
+ const archivedQueryParams = $derived(
149
+ useServerPagination
150
+ ? {
151
+ slug: collection.slug,
152
+ onlyArchived: true as const,
153
+ limit: viewState.pageSize,
154
+ offset: viewState.pageIndex * viewState.pageSize,
155
+ orderBy: serverOrderBy
156
+ }
157
+ : { slug: collection.slug, onlyArchived: true as const }
158
+ );
159
+
120
160
  const columns: ColumnDef<CollectionDataTableRow>[] = $derived([
121
161
  {
122
162
  id: 'select',
@@ -232,13 +272,15 @@
232
272
  }
233
273
  ]);
234
274
 
235
- let activeTab = $state('active');
275
+ function refreshQueries() {
276
+ remotes.getRawEntries(activeQueryParams).refresh();
277
+ remotes.getRawEntries(archivedQueryParams).refresh();
278
+ }
236
279
 
237
280
  async function handleRestore(id: string) {
238
281
  await remotes.unarchiveEntryCommand(id);
239
282
  toast.success(lang[interfaceLanguage.current].entryRestored);
240
- remotes.getRawEntries({ slug: collection.slug, onlyArchived: true }).refresh();
241
- remotes.getRawEntries({ slug: collection.slug }).refresh();
283
+ refreshQueries();
242
284
  }
243
285
 
244
286
  function handleDelete(id: string) {
@@ -250,7 +292,7 @@
250
292
  if (!pendingDeleteId) return;
251
293
  await remotes.deleteEntryCommand(pendingDeleteId);
252
294
  toast.success(lang[interfaceLanguage.current].entryDeleted);
253
- remotes.getRawEntries({ slug: collection.slug, onlyArchived: true }).refresh();
295
+ refreshQueries();
254
296
  deleteDialogOpen = false;
255
297
  pendingDeleteId = null;
256
298
  }
@@ -319,12 +361,18 @@
319
361
  }
320
362
  toast.success(lang[interfaceLanguage.current].entriesArchived);
321
363
  rowSelection = {};
322
- remotes.getRawEntries({ slug: collection.slug }).refresh();
323
- remotes.getRawEntries({ slug: collection.slug, onlyArchived: true }).refresh();
364
+ refreshQueries();
324
365
  }
366
+
325
367
  </script>
326
368
 
327
- <Tabs.Root bind:value={activeTab} class="w-full">
369
+ <Tabs.Root
370
+ value={viewState.activeTab}
371
+ onValueChange={(v) => {
372
+ if (v === 'active' || v === 'archived') viewState.activeTab = v;
373
+ }}
374
+ class="w-full"
375
+ >
328
376
  <div class="flex items-center justify-between border-b border-slate-200/50 px-4 py-3 dark:border-white/10">
329
377
  <Tabs.List class="h-auto gap-1 bg-transparent p-0">
330
378
  <Tabs.Trigger value="active" class="rounded-lg px-3 py-1.5 text-sm font-medium data-[state=active]:bg-slate-100 data-[state=active]:text-slate-900 dark:data-[state=active]:bg-slate-800 dark:data-[state=active]:text-white">{lang[interfaceLanguage.current].active}</Tabs.Trigger>
@@ -336,57 +384,94 @@
336
384
  </div>
337
385
 
338
386
  <Tabs.Content value="active" class="mt-0">
339
- {#await remotes.getRawEntries({ slug: collection.slug }) then entries}
340
- {@const items = entries
341
- .filter((entry) => entry.archivedAt === null)
342
- .map(mapEntryToRow)}
343
- {@const filteredItems = searchQuery
344
- ? items.filter((item) => item.searchText.includes(searchQuery.toLowerCase()))
345
- : items}
346
- {@const totalItems = filteredItems.length}
387
+ {#await remotes.getRawEntries(activeQueryParams) then result}
388
+ {@const allRows = result.entries.map(mapEntryToRow)}
389
+ {@const items = useServerPagination
390
+ ? allRows
391
+ : (() => {
392
+ let filtered = searchQuery
393
+ ? allRows.filter((item) => item.searchText.includes(searchQuery.toLowerCase()))
394
+ : allRows;
395
+ return sortItems(filtered, viewState.sorting);
396
+ })()}
397
+ {@const totalItems = useServerPagination ? result.total : items.length}
347
398
  {@const pageCount = Math.ceil(totalItems / viewState.pageSize)}
348
399
 
349
400
  <TableToolbar
350
401
  {searchQuery}
351
- onSearchChange={(q) => (searchQuery = q)}
402
+ onSearchChange={(q) => {
403
+ searchQuery = q;
404
+ viewState.pageIndex = 0;
405
+ }}
352
406
  viewMode={viewState.viewMode}
353
407
  onViewModeChange={(m) => (viewState.viewMode = m)}
354
408
  dateFormat={viewState.dateFormat}
355
409
  onDateFormatChange={(f) => (viewState.dateFormat = f)}
356
410
  {selectedCount}
357
- onBulkArchive={() => handleBulkArchive(sortItems(filteredItems, viewState.sorting))}
411
+ onBulkArchive={() => handleBulkArchive(items)}
358
412
  />
359
413
 
360
414
  {#if viewState.viewMode === 'list'}
361
- <DataTable
362
- data={sortItems(filteredItems, viewState.sorting)}
363
- {columns}
364
- enableSorting
365
- enableFiltering
366
- enableSelection
367
- enablePagination
368
- sorting={viewState.sorting}
369
- onSortingChange={(s) => (viewState.sorting = s)}
370
- {rowSelection}
371
- onRowSelectionChange={(s) => (rowSelection = s)}
372
- pagination={{ pageIndex: 0, pageSize: viewState.pageSize }}
373
- tableRef={(t) => (tableInstance = t)}
374
- />
415
+ {#if useServerPagination}
416
+ <DataTable
417
+ data={items}
418
+ {columns}
419
+ enableSorting
420
+ enableFiltering
421
+ enableSelection
422
+ enablePagination
423
+ manualPagination={true}
424
+ pageCount={pageCount}
425
+ rowCount={totalItems}
426
+ sorting={viewState.sorting}
427
+ onSortingChange={(s) => (viewState.sorting = s)}
428
+ {rowSelection}
429
+ onRowSelectionChange={(s) => (rowSelection = s)}
430
+ pagination={{ pageIndex: viewState.pageIndex, pageSize: viewState.pageSize }}
431
+ onPaginationChange={(p) => {
432
+ viewState.pageIndex = p.pageIndex;
433
+ }}
434
+ tableRef={(t) => (tableInstance = t)}
435
+ />
436
+ {:else}
437
+ <DataTable
438
+ data={items}
439
+ {columns}
440
+ enableSorting
441
+ enableFiltering
442
+ enableSelection
443
+ enablePagination
444
+ sorting={viewState.sorting}
445
+ onSortingChange={(s) => (viewState.sorting = s)}
446
+ {rowSelection}
447
+ onRowSelectionChange={(s) => (rowSelection = s)}
448
+ pagination={{ pageIndex: viewState.pageIndex, pageSize: viewState.pageSize }}
449
+ onPaginationChange={(p) => {
450
+ viewState.pageIndex = p.pageIndex;
451
+ }}
452
+ tableRef={(t) => (tableInstance = t)}
453
+ />
454
+ {/if}
375
455
 
376
456
  <TablePagination
377
- pageIndex={tableInstance?.getState().pagination.pageIndex ?? 0}
457
+ pageIndex={viewState.pageIndex}
378
458
  pageSize={viewState.pageSize}
379
459
  {pageCount}
380
460
  {totalItems}
381
- onPageChange={(p) => tableInstance?.setPageIndex(p)}
461
+ onPageChange={(p) => (viewState.pageIndex = p)}
382
462
  onPageSizeChange={(s) => {
383
463
  viewState.pageSize = s;
384
- tableInstance?.setPageSize(s);
385
464
  }}
386
465
  />
387
466
  {:else}
467
+ {@const displayItems = useServerPagination
468
+ ? items
469
+ : items.slice(
470
+ viewState.pageIndex * viewState.pageSize,
471
+ (viewState.pageIndex + 1) * viewState.pageSize
472
+ )}
388
473
  <GridView
389
- items={sortItems(filteredItems, viewState.sorting).map((item) => ({
474
+ items={displayItems.map((item) => ({
390
475
  id: item.id,
391
476
  name: item.name,
392
477
  status: item.status,
@@ -396,12 +481,11 @@
396
481
  }))}
397
482
  dateFormat={viewState.dateFormat}
398
483
  enableSelection
399
- selectedIds={new Set(selectedIndices.map((idx) => sortItems(filteredItems, viewState.sorting)[idx]?.id).filter(Boolean))}
484
+ selectedIds={new Set(selectedIndices.map((idx) => items[idx]?.id).filter(Boolean))}
400
485
  onSelectionChange={(ids) => {
401
- const sorted = sortItems(filteredItems, viewState.sorting);
402
486
  const newSelection: RowSelectionState = {};
403
487
  for (const id of ids) {
404
- const idx = sorted.findIndex((i) => i.id === id);
488
+ const idx = items.findIndex((i) => i.id === id);
405
489
  if (idx !== -1) {
406
490
  newSelection[idx] = true;
407
491
  }
@@ -409,20 +493,61 @@
409
493
  rowSelection = newSelection;
410
494
  }}
411
495
  />
496
+
497
+ <TablePagination
498
+ pageIndex={viewState.pageIndex}
499
+ pageSize={viewState.pageSize}
500
+ {pageCount}
501
+ {totalItems}
502
+ onPageChange={(p) => (viewState.pageIndex = p)}
503
+ onPageSizeChange={(s) => {
504
+ viewState.pageSize = s;
505
+ }}
506
+ />
412
507
  {/if}
413
508
  {/await}
414
509
  </Tabs.Content>
415
510
 
416
511
  <Tabs.Content value="archived" class="mt-0">
417
- {#await remotes.getRawEntries({ slug: collection.slug, onlyArchived: true }) then entries}
418
- {@const items = entries.map((entry) => ({
512
+ {#await remotes.getRawEntries(archivedQueryParams) then result}
513
+ {@const items = result.entries.map((entry) => ({
419
514
  ...mapEntryToRow(entry),
420
515
  onRestore: () => handleRestore(entry.id),
421
516
  onDelete: () => handleDelete(entry.id)
422
517
  }))}
423
- <div class="p-4">
424
- <DataTable data={items} columns={archivedColumns} />
425
- </div>
518
+ {@const totalItems = result.total}
519
+ {@const pageCount = Math.ceil(totalItems / viewState.pageSize)}
520
+
521
+ {#if useServerPagination}
522
+ <div class="p-4">
523
+ <DataTable
524
+ data={items}
525
+ columns={archivedColumns}
526
+ manualPagination={true}
527
+ pageCount={pageCount}
528
+ rowCount={totalItems}
529
+ enablePagination
530
+ pagination={{ pageIndex: viewState.pageIndex, pageSize: viewState.pageSize }}
531
+ onPaginationChange={(p) => {
532
+ viewState.pageIndex = p.pageIndex;
533
+ }}
534
+ />
535
+ </div>
536
+ <TablePagination
537
+ pageIndex={viewState.pageIndex}
538
+ pageSize={viewState.pageSize}
539
+ {pageCount}
540
+ {totalItems}
541
+ onPageChange={(p) => (viewState.pageIndex = p)}
542
+ onPageSizeChange={(s) => {
543
+ viewState.pageSize = s;
544
+ }}
545
+ />
546
+ {:else}
547
+ <div class="p-4">
548
+ <DataTable data={items} columns={archivedColumns} />
549
+ </div>
550
+ {/if}
426
551
  {/await}
427
552
  </Tabs.Content>
428
553
  </Tabs.Root>
@@ -1,15 +1,20 @@
1
1
  import type { SortingState } from '@tanstack/table-core';
2
2
  export type ViewMode = 'list' | 'grid';
3
3
  export type DateFormat = 'relative' | 'absolute';
4
+ export type ActiveTab = 'active' | 'archived';
4
5
  export interface CollectionViewState {
5
6
  viewMode: ViewMode;
6
7
  dateFormat: DateFormat;
7
8
  pageSize: number;
8
9
  sorting: SortingState;
10
+ pageIndex: number;
11
+ activeTab: ActiveTab;
9
12
  }
10
13
  export declare function createCollectionViewState(collectionSlug: string): {
11
14
  viewMode: ViewMode;
12
15
  dateFormat: DateFormat;
13
16
  pageSize: number;
14
17
  sorting: SortingState;
18
+ pageIndex: number;
19
+ activeTab: ActiveTab;
15
20
  };