includio-cms 0.1.0 → 0.1.2

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 (59) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/ROADMAP.md +17 -15
  3. package/dist/admin/auth-client.d.ts +1165 -5
  4. package/dist/admin/auth-client.js +4 -1
  5. package/dist/admin/client/account/sessions-section.svelte +1 -21
  6. package/dist/admin/client/index.d.ts +1 -0
  7. package/dist/admin/client/index.js +1 -0
  8. package/dist/admin/client/users/accept-invite-page.svelte +118 -0
  9. package/dist/admin/client/users/accept-invite-page.svelte.d.ts +4 -0
  10. package/dist/admin/client/users/create-user-dialog.svelte +157 -0
  11. package/dist/admin/client/users/create-user-dialog.svelte.d.ts +8 -0
  12. package/dist/admin/client/users/delete-user-dialog.svelte +53 -0
  13. package/dist/admin/client/users/delete-user-dialog.svelte.d.ts +10 -0
  14. package/dist/admin/client/users/edit-user-dialog.svelte +127 -0
  15. package/dist/admin/client/users/edit-user-dialog.svelte.d.ts +16 -0
  16. package/dist/admin/client/users/invite-user-dialog.svelte +107 -0
  17. package/dist/admin/client/users/invite-user-dialog.svelte.d.ts +8 -0
  18. package/dist/admin/client/users/lang.d.ts +57 -0
  19. package/dist/admin/client/users/lang.js +114 -0
  20. package/dist/admin/client/users/pending-invitations.svelte +145 -0
  21. package/dist/admin/client/users/pending-invitations.svelte.d.ts +6 -0
  22. package/dist/admin/client/users/user-sessions-sheet.svelte +141 -0
  23. package/dist/admin/client/users/user-sessions-sheet.svelte.d.ts +8 -0
  24. package/dist/admin/client/users/users-page.svelte +262 -0
  25. package/dist/admin/client/users/users-page.svelte.d.ts +6 -0
  26. package/dist/admin/components/fields/array-field.svelte +68 -22
  27. package/dist/admin/components/fields/field-renderer.svelte +25 -2
  28. package/dist/admin/components/fields/number-field.svelte +1 -1
  29. package/dist/admin/components/fields/text-field-wrapper.svelte +56 -1
  30. package/dist/admin/components/fields/text-field.svelte +2 -2
  31. package/dist/admin/components/layout/lang.d.ts +1 -0
  32. package/dist/admin/components/layout/lang.js +4 -2
  33. package/dist/admin/components/layout/nav-main.svelte +15 -1
  34. package/dist/admin/remote/invite.d.ts +44 -0
  35. package/dist/admin/remote/invite.js +44 -0
  36. package/dist/admin/remote/middleware/auth.d.ts +5 -0
  37. package/dist/admin/remote/middleware/auth.js +7 -0
  38. package/dist/admin/utils/parseUserAgent.d.ts +5 -0
  39. package/dist/admin/utils/parseUserAgent.js +26 -0
  40. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  41. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  42. package/dist/core/cms.d.ts +1 -1
  43. package/dist/core/cms.js +1 -1
  44. package/dist/core/fields/fieldSchemaToTs.js +18 -4
  45. package/dist/core/server/forms/submissions/operations/create.js +1 -1
  46. package/dist/email-nodemailer/index.d.ts +1 -0
  47. package/dist/server/auth.d.ts +8 -8
  48. package/dist/server/db/schema/auth-schema.d.ts +143 -0
  49. package/dist/server/db/schema/auth-schema.js +12 -0
  50. package/dist/sveltekit/server/handle.js +13 -0
  51. package/dist/types/cms.d.ts +2 -2
  52. package/dist/types/roles.d.ts +1 -0
  53. package/dist/types/roles.js +1 -0
  54. package/dist/updates/0.1.1/index.d.ts +2 -0
  55. package/dist/updates/0.1.1/index.js +17 -0
  56. package/dist/updates/0.1.2/index.d.ts +2 -0
  57. package/dist/updates/0.1.2/index.js +36 -0
  58. package/dist/updates/index.js +3 -1
  59. package/package.json +2 -2
@@ -0,0 +1,262 @@
1
+ <script lang="ts">
2
+ import Search from '@tabler/icons-svelte/icons/search';
3
+ import Plus from '@tabler/icons-svelte/icons/plus';
4
+ import Pencil from '@tabler/icons-svelte/icons/pencil';
5
+ import Trash from '@tabler/icons-svelte/icons/trash';
6
+ import Loader2 from '@tabler/icons-svelte/icons/loader-2';
7
+ import DeviceDesktop from '@tabler/icons-svelte/icons/device-desktop';
8
+ import Mail from '@tabler/icons-svelte/icons/mail';
9
+ import Input from '../../../components/ui/input/input.svelte';
10
+ import Button from '../../../components/ui/button/button.svelte';
11
+ import { Badge } from '../../../components/ui/badge/index.js';
12
+ import * as Table from '../../../components/ui/table/index.js';
13
+ import { authClient } from '../../auth-client.js';
14
+ import { usersLang } from './lang.js';
15
+ import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
16
+ import { toLocaleCode } from '../../utils/formatDate.js';
17
+ import CreateUserDialog from './create-user-dialog.svelte';
18
+ import EditUserDialog from './edit-user-dialog.svelte';
19
+ import DeleteUserDialog from './delete-user-dialog.svelte';
20
+ import UserSessionsSheet from './user-sessions-sheet.svelte';
21
+ import InviteUserDialog from './invite-user-dialog.svelte';
22
+ import PendingInvitations from './pending-invitations.svelte';
23
+
24
+ type Props = {
25
+ emailConfigured?: boolean;
26
+ };
27
+
28
+ let { emailConfigured = false }: Props = $props();
29
+
30
+ type User = {
31
+ id: string;
32
+ name: string;
33
+ email: string;
34
+ role: string;
35
+ createdAt: Date;
36
+ };
37
+
38
+ const interfaceLanguage = useInterfaceLanguage();
39
+ const lang = $derived(usersLang[interfaceLanguage.current]);
40
+ const session = authClient.useSession();
41
+ const currentUserId = $derived(session.value?.data?.user?.id ?? '');
42
+
43
+ let users = $state<User[]>([]);
44
+ let loading = $state(true);
45
+ let searchQuery = $state('');
46
+
47
+ // Dialogs
48
+ let createOpen = $state(false);
49
+ let editOpen = $state(false);
50
+ let deleteOpen = $state(false);
51
+ let inviteOpen = $state(false);
52
+ let editingUser = $state<User | null>(null);
53
+ let deletingUserId = $state<string | null>(null);
54
+
55
+ // Sessions sheet
56
+ let sessionsOpen = $state(false);
57
+ let sessionsUserId = $state<string | null>(null);
58
+ let sessionsUserName = $state('');
59
+
60
+ // Invite refresh
61
+ let inviteRefresh = $state(0);
62
+
63
+ // Pagination
64
+ let pageIndex = $state(0);
65
+ const pageSize = 20;
66
+
67
+ const filtered = $derived(
68
+ searchQuery
69
+ ? users.filter(
70
+ (u) =>
71
+ u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
72
+ u.email.toLowerCase().includes(searchQuery.toLowerCase())
73
+ )
74
+ : users
75
+ );
76
+
77
+ const totalItems = $derived(filtered.length);
78
+ const pageCount = $derived(Math.ceil(totalItems / pageSize));
79
+ const paged = $derived(filtered.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize));
80
+
81
+ $effect(() => {
82
+ loadUsers();
83
+ });
84
+
85
+ async function loadUsers() {
86
+ loading = true;
87
+ const { data, error } = await authClient.admin.listUsers({
88
+ query: { limit: 500 }
89
+ });
90
+ if (data) {
91
+ users = data.users.map((u: any) => ({
92
+ id: u.id,
93
+ name: u.name ?? '',
94
+ email: u.email,
95
+ role: u.role ?? 'user',
96
+ createdAt: new Date(u.createdAt)
97
+ }));
98
+ }
99
+ loading = false;
100
+ }
101
+
102
+ function formatDate(date: Date): string {
103
+ return date.toLocaleString(toLocaleCode(interfaceLanguage.current), {
104
+ year: 'numeric',
105
+ month: 'short',
106
+ day: 'numeric'
107
+ });
108
+ }
109
+
110
+ function openEdit(user: User) {
111
+ editingUser = user;
112
+ editOpen = true;
113
+ }
114
+
115
+ function openDelete(userId: string) {
116
+ deletingUserId = userId;
117
+ deleteOpen = true;
118
+ }
119
+
120
+ function openSessions(user: User) {
121
+ sessionsUserId = user.id;
122
+ sessionsUserName = user.name;
123
+ sessionsOpen = true;
124
+ }
125
+ </script>
126
+
127
+ <div class="container mx-auto max-w-5xl px-4 py-8">
128
+ <div class="mb-6 flex items-center justify-between">
129
+ <h1 class="text-2xl font-bold">{lang.title}</h1>
130
+ <div class="flex items-center gap-2">
131
+ {#if emailConfigured}
132
+ <Button variant="outline" size="sm" onclick={() => (inviteOpen = true)}>
133
+ <Mail class="size-4" />
134
+ {lang.invite.inviteUser}
135
+ </Button>
136
+ {/if}
137
+ <Button variant="gradient" size="sm" onclick={() => (createOpen = true)}>
138
+ <Plus class="size-4" />
139
+ {lang.createUser}
140
+ </Button>
141
+ </div>
142
+ </div>
143
+
144
+ <div class="relative mb-4">
145
+ <Search class="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
146
+ <Input
147
+ type="text"
148
+ placeholder={lang.search}
149
+ class="pl-9"
150
+ value={searchQuery}
151
+ oninput={(e) => {
152
+ searchQuery = e.currentTarget.value;
153
+ pageIndex = 0;
154
+ }}
155
+ />
156
+ </div>
157
+
158
+ {#if loading}
159
+ <div class="flex items-center justify-center py-16">
160
+ <Loader2 class="text-muted-foreground size-6 animate-spin" />
161
+ </div>
162
+ {:else if paged.length === 0}
163
+ <p class="text-muted-foreground py-16 text-center">{lang.noResults}</p>
164
+ {:else}
165
+ <div class="overflow-hidden rounded-2xl border">
166
+ <Table.Root>
167
+ <Table.Header>
168
+ <Table.Row>
169
+ <Table.Head>{lang.name}</Table.Head>
170
+ <Table.Head>{lang.email}</Table.Head>
171
+ <Table.Head>{lang.role}</Table.Head>
172
+ <Table.Head>{lang.createdAt}</Table.Head>
173
+ <Table.Head class="text-right">{lang.actions}</Table.Head>
174
+ </Table.Row>
175
+ </Table.Header>
176
+ <Table.Body>
177
+ {#each paged as user (user.id)}
178
+ <Table.Row class="hover:bg-slate-50 dark:hover:bg-slate-800/50">
179
+ <Table.Cell class="font-medium">{user.name}</Table.Cell>
180
+ <Table.Cell>{user.email}</Table.Cell>
181
+ <Table.Cell>
182
+ <Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
183
+ {user.role === 'admin' ? lang.roleAdmin : lang.roleUser}
184
+ </Badge>
185
+ </Table.Cell>
186
+ <Table.Cell>{formatDate(user.createdAt)}</Table.Cell>
187
+ <Table.Cell class="text-right">
188
+ <div class="flex items-center justify-end gap-1">
189
+ <Button variant="ghost" size="icon" class="h-8 w-8" onclick={() => openSessions(user)}>
190
+ <DeviceDesktop class="size-4" />
191
+ </Button>
192
+ <Button variant="ghost" size="icon" class="h-8 w-8" onclick={() => openEdit(user)}>
193
+ <Pencil class="size-4" />
194
+ </Button>
195
+ {#if user.id !== currentUserId}
196
+ <Button
197
+ variant="ghost"
198
+ size="icon"
199
+ class="text-destructive h-8 w-8"
200
+ onclick={() => openDelete(user.id)}
201
+ >
202
+ <Trash class="size-4" />
203
+ </Button>
204
+ {/if}
205
+ </div>
206
+ </Table.Cell>
207
+ </Table.Row>
208
+ {/each}
209
+ </Table.Body>
210
+ </Table.Root>
211
+ </div>
212
+
213
+ {#if pageCount > 1}
214
+ <div class="mt-4 flex items-center justify-center gap-2">
215
+ <Button variant="outline" size="sm" disabled={pageIndex === 0} onclick={() => (pageIndex -= 1)}>
216
+ &larr;
217
+ </Button>
218
+ <span class="text-muted-foreground text-sm">
219
+ {pageIndex + 1} / {pageCount}
220
+ </span>
221
+ <Button
222
+ variant="outline"
223
+ size="sm"
224
+ disabled={pageIndex >= pageCount - 1}
225
+ onclick={() => (pageIndex += 1)}
226
+ >
227
+ &rarr;
228
+ </Button>
229
+ </div>
230
+ {/if}
231
+ {/if}
232
+
233
+ {#if emailConfigured}
234
+ <PendingInvitations refreshTrigger={inviteRefresh} />
235
+ {/if}
236
+ </div>
237
+
238
+ <CreateUserDialog bind:open={createOpen} onOpenChange={(v) => (createOpen = v)} onCreated={loadUsers} />
239
+
240
+ <EditUserDialog
241
+ bind:open={editOpen}
242
+ onOpenChange={(v) => (editOpen = v)}
243
+ onUpdated={loadUsers}
244
+ user={editingUser}
245
+ {currentUserId}
246
+ />
247
+
248
+ <DeleteUserDialog
249
+ bind:open={deleteOpen}
250
+ onOpenChange={(v) => (deleteOpen = v)}
251
+ onDeleted={loadUsers}
252
+ userId={deletingUserId}
253
+ {currentUserId}
254
+ />
255
+
256
+ <UserSessionsSheet bind:open={sessionsOpen} userId={sessionsUserId} userName={sessionsUserName} />
257
+
258
+ <InviteUserDialog
259
+ bind:open={inviteOpen}
260
+ onOpenChange={(v) => (inviteOpen = v)}
261
+ onInvited={() => (inviteRefresh += 1)}
262
+ />
@@ -0,0 +1,6 @@
1
+ type Props = {
2
+ emailConfigured?: boolean;
3
+ };
4
+ declare const UsersPage: import("svelte").Component<Props, {}, "">;
5
+ type UsersPage = ReturnType<typeof UsersPage>;
6
+ export default UsersPage;
@@ -71,9 +71,28 @@
71
71
  acc.push(item);
72
72
  return acc;
73
73
  }, [] as ObjectFieldData[]);
74
+
75
+ // Fixed-length: pad or trim to exact count
76
+ if (isFixedLength && $value.length !== fixedCount) {
77
+ const current = [...$value];
78
+ if (current.length < fixedCount) {
79
+ const defaults = field.defaultValue;
80
+ for (let i = current.length; i < fixedCount; i++) {
81
+ if (defaults && defaults[i]) {
82
+ current.push({ _id: generateId(), ...JSON.parse(JSON.stringify(defaults[i])) });
83
+ } else {
84
+ current.push({ _id: generateId(), slug: field.of[0].slug, data: {} });
85
+ }
86
+ }
87
+ } else {
88
+ current.length = fixedCount;
89
+ }
90
+ $value = current;
91
+ }
74
92
  });
75
93
 
76
94
  async function addItem(field: ObjectFieldType) {
95
+ if (atMax) return;
77
96
  $value = [
78
97
  ...($value ?? []),
79
98
  {
@@ -87,7 +106,7 @@
87
106
  }
88
107
 
89
108
  function duplicateItem(index: number) {
90
- if (!$value) return;
109
+ if (!$value || atMax) return;
91
110
  const itemToDuplicate = $value[index];
92
111
  if (!itemToDuplicate) return;
93
112
 
@@ -192,11 +211,28 @@
192
211
  return normalizePath(flashingPath) === fieldPath;
193
212
  }
194
213
 
214
+ const atMax = $derived(field.maxItems !== undefined && ($value?.length ?? 0) >= field.maxItems);
215
+
216
+ const isFixedLength = $derived(
217
+ field.minItems !== undefined &&
218
+ field.maxItems !== undefined &&
219
+ field.minItems === field.maxItems &&
220
+ field.minItems > 0
221
+ );
222
+ const fixedCount = $derived(isFixedLength ? field.minItems! : 0);
223
+
195
224
  let blockPickerOpen = $state(false);
196
225
  </script>
197
226
 
198
227
  <div class="flex items-center justify-between gap-4">
199
- <RequiredLabel required={field.minItems !== undefined && field.minItems > 0} class="text-lg">{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
228
+ <div class="flex items-center gap-2">
229
+ <RequiredLabel required={field.minItems !== undefined && field.minItems > 0} class="text-lg">{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
230
+ {#if isFixedLength}
231
+ <span class="text-xs text-muted-foreground">{fixedCount} elementów</span>
232
+ {:else if field.maxItems !== undefined}
233
+ <span class="text-xs {atMax ? 'text-destructive' : 'text-muted-foreground'}">{$value?.length ?? 0} / {field.maxItems}</span>
234
+ {/if}
235
+ </div>
200
236
 
201
237
  <div class="flex items-center gap-2">
202
238
  <Button
@@ -263,6 +299,7 @@
263
299
  >
264
300
  <div class="flex grow items-center justify-between gap-4">
265
301
  <div class="flex items-center gap-4">
302
+ {#if !isFixedLength || fixedCount > 1}
266
303
  <!-- svelte-ignore a11y_no_static_element_interactions -->
267
304
  <div
268
305
  use:draggable={{ container: index.toString(), dragData: { id: item._id } }}
@@ -272,11 +309,13 @@
272
309
  >
273
310
  <GripVertical class="h-4 w-4" />
274
311
  </div>
312
+ {/if}
275
313
  <span>{index < 10 ? '0' : ''}{index + 1}</span>
276
314
  <Badge variant="outline">{getLocalizedLabel(objectField.label, interfaceLanguage.current) || objectField.slug}</Badge>
277
315
  <span>{getAccordionLabel($value[index])}</span>
278
316
  </div>
279
317
 
318
+ {#if !(isFixedLength && fixedCount <= 1)}
280
319
  <DropdownMenu.Root>
281
320
  <DropdownMenu.Trigger
282
321
  class="data-[state=open]:bg-muted text-muted-foreground flex size-8"
@@ -289,9 +328,11 @@
289
328
  {/snippet}
290
329
  </DropdownMenu.Trigger>
291
330
  <DropdownMenu.Content align="end" class="w-32">
292
- <DropdownMenu.Item onclick={() => duplicateItem(index)}>
331
+ {#if !isFixedLength}
332
+ <DropdownMenu.Item onclick={() => duplicateItem(index)} disabled={atMax}>
293
333
  Duplicate
294
334
  </DropdownMenu.Item>
335
+ {/if}
295
336
  <DropdownMenu.Item onclick={() => moveItemUp(index)} disabled={index === 0}>
296
337
  Move up
297
338
  </DropdownMenu.Item>
@@ -301,11 +342,14 @@
301
342
  >
302
343
  Move down
303
344
  </DropdownMenu.Item>
345
+ {#if !isFixedLength}
304
346
  <DropdownMenu.Item variant="destructive" onclick={() => removeItem(index)}>
305
347
  Delete
306
348
  </DropdownMenu.Item>
349
+ {/if}
307
350
  </DropdownMenu.Content>
308
351
  </DropdownMenu.Root>
352
+ {/if}
309
353
  </div>
310
354
  </Accordion.Trigger>
311
355
  <Accordion.Content
@@ -341,25 +385,27 @@
341
385
  {/if}
342
386
  </Accordion.Root>
343
387
 
344
- {#if field.displayMode === 'blocks'}
345
- <div class="mt-4">
346
- <Button size="sm" type="button" variant="outline" onclick={() => (blockPickerOpen = true)}>
347
- <CirclePlus />
348
- Add Block
349
- </Button>
350
- </div>
351
- <BlockPickerModal
352
- bind:open={blockPickerOpen}
353
- options={field.of}
354
- onSelect={(option) => addItem(option)}
355
- />
356
- {:else}
357
- <div class="mt-4 flex flex-wrap gap-2">
358
- {#each field.of as option}
359
- <Button size="sm" type="button" variant="outline" onclick={() => addItem(option)}>
388
+ {#if !isFixedLength}
389
+ {#if field.displayMode === 'blocks'}
390
+ <div class="mt-4">
391
+ <Button size="sm" type="button" variant="outline" disabled={atMax} onclick={() => (blockPickerOpen = true)}>
360
392
  <CirclePlus />
361
- {getLocalizedLabel(option.label, interfaceLanguage.current) || option.slug}
393
+ Add Block
362
394
  </Button>
363
- {/each}
364
- </div>
395
+ </div>
396
+ <BlockPickerModal
397
+ bind:open={blockPickerOpen}
398
+ options={field.of}
399
+ onSelect={(option) => addItem(option)}
400
+ />
401
+ {:else}
402
+ <div class="mt-4 flex flex-wrap gap-2">
403
+ {#each field.of as option}
404
+ <Button size="sm" type="button" variant="outline" disabled={atMax} onclick={() => addItem(option)}>
405
+ <CirclePlus />
406
+ {getLocalizedLabel(option.label, interfaceLanguage.current) || option.slug}
407
+ </Button>
408
+ {/each}
409
+ </div>
410
+ {/if}
365
411
  {/if}
@@ -48,6 +48,23 @@
48
48
  function isCheckboxesField(field: Field): field is Extract<Field, { type: 'checkboxes' }> {
49
49
  return field.type === 'checkboxes';
50
50
  }
51
+
52
+ function numberConstraintHint(f: Field): string {
53
+ if (f.type !== 'number') return '';
54
+ const parts: string[] = [];
55
+ if (f.min !== undefined && f.max !== undefined) {
56
+ parts.push(`Zakres: ${f.min}–${f.max}`);
57
+ } else if (f.min !== undefined) {
58
+ parts.push(`Min. ${f.min}`);
59
+ } else if (f.max !== undefined) {
60
+ parts.push(`Maks. ${f.max}`);
61
+ }
62
+ if (f.step !== undefined) {
63
+ if (parts.length > 0) parts.push(` · `);
64
+ parts.push(`Krok: ${f.step}`);
65
+ }
66
+ return parts.join('');
67
+ }
51
68
  </script>
52
69
 
53
70
  {#if isRadioField(field)}
@@ -132,8 +149,14 @@
132
149
  </div>
133
150
  {/snippet}
134
151
  </Form.Control>
135
- {#if !fieldsWithNoDescription.includes(field.type) && !fieldsWithAlternativeDescription.includes(field.type) && field.description}
136
- <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
152
+ {#if !fieldsWithNoDescription.includes(field.type) && !fieldsWithAlternativeDescription.includes(field.type) && (field.description || numberConstraintHint(field))}
153
+ <Form.Description>
154
+ {#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
155
+ {#if numberConstraintHint(field)}
156
+ {#if field.description}<br />{/if}
157
+ {numberConstraintHint(field)}
158
+ {/if}
159
+ </Form.Description>
137
160
  {/if}
138
161
  <Form.FieldErrors />
139
162
  </Form.Field>
@@ -31,4 +31,4 @@
31
31
  });
32
32
  </script>
33
33
 
34
- <Input {...props} bind:value={$value} type="number" />
34
+ <Input {...props} bind:value={$value} type="number" min={field.min} max={field.max} step={field.step} />
@@ -27,6 +27,30 @@
27
27
  };
28
28
 
29
29
  let { field, form, path, ...props }: Props = $props();
30
+
31
+ const formData = form.form;
32
+
33
+ const isText = $derived(field.type === 'text');
34
+ const hasConstraints = $derived(
35
+ isText && (field.minLength !== undefined || field.maxLength !== undefined || field.pattern !== undefined)
36
+ );
37
+
38
+ function resolvePathValue(data: Record<string, unknown>, dotPath: string): unknown {
39
+ return dotPath.split('.').reduce<unknown>((obj, key) => (obj as Record<string, unknown>)?.[key], data);
40
+ }
41
+
42
+ function constraintHint(): string {
43
+ if (field.type !== 'text') return '';
44
+ const parts: string[] = [];
45
+ if (field.minLength !== undefined && field.maxLength !== undefined) {
46
+ parts.push(`${field.minLength}–${field.maxLength} znaków`);
47
+ } else if (field.minLength !== undefined) {
48
+ parts.push(`Min. ${field.minLength} znaków`);
49
+ } else if (field.maxLength !== undefined) {
50
+ parts.push(`Maks. ${field.maxLength} znaków`);
51
+ }
52
+ return parts.join('');
53
+ }
30
54
  </script>
31
55
 
32
56
  <Tabs.Root
@@ -66,7 +90,38 @@
66
90
  {/snippet}
67
91
  </Form.Control>
68
92
 
69
- <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
93
+ {#if isText}
94
+ {@const val = resolvePathValue($formData, joinPath(path, lang))}
95
+ {@const charCount = typeof val === 'string' ? val.length : 0}
96
+ {@const atLimit = field.type === 'text' && field.maxLength !== undefined && charCount >= field.maxLength}
97
+ <div class="flex items-start justify-between gap-4">
98
+ {#if field.description || hasConstraints}
99
+ <Form.Description class="flex-1">
100
+ {#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
101
+ {#if hasConstraints}
102
+ {#if field.description && constraintHint()}<br />{/if}
103
+ {#if constraintHint()}{constraintHint()}{/if}
104
+ {#if field.type === 'text' && field.pattern}
105
+ {#if constraintHint()} · {/if}Format: <code class="text-xs bg-muted px-1 py-0.5 rounded">{field.pattern}</code>
106
+ {/if}
107
+ {/if}
108
+ </Form.Description>
109
+ {:else}
110
+ <div></div>
111
+ {/if}
112
+ <span class="shrink-0 text-xs {atLimit ? 'text-destructive' : 'text-muted-foreground'}" aria-live="polite">
113
+ {#if field.type === 'text' && field.maxLength !== undefined}
114
+ {charCount} / {field.maxLength}
115
+ {:else if field.type === 'text' && field.minLength !== undefined}
116
+ {charCount} (min. {field.minLength})
117
+ {:else}
118
+ {charCount}
119
+ {/if}
120
+ </span>
121
+ </div>
122
+ {:else if field.description}
123
+ <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
124
+ {/if}
70
125
 
71
126
  <Form.FieldErrors />
72
127
  </Form.Field>
@@ -35,7 +35,7 @@
35
35
  </script>
36
36
 
37
37
  {#if field.multiline}
38
- <Textarea {...props} bind:value={$value} placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} />
38
+ <Textarea {...props} bind:value={$value} placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} minlength={field.minLength} maxlength={field.maxLength} />
39
39
  {:else}
40
- <Input {...props} bind:value={$value} type="text" placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} />
40
+ <Input {...props} bind:value={$value} type="text" placeholder={getLocalizedLabel(field.placeholder, interfaceLanguage.current)} minlength={field.minLength} maxlength={field.maxLength} />
41
41
  {/if}
@@ -7,6 +7,7 @@ export declare const sidebarLang: Record<string, {
7
7
  platform: string;
8
8
  media: string;
9
9
  dashboard: string;
10
+ users: string;
10
11
  };
11
12
  collections: {
12
13
  title: string;
@@ -7,7 +7,8 @@ export const sidebarLang = {
7
7
  main: {
8
8
  platform: 'Platforma',
9
9
  media: 'Biblioteka mediów',
10
- dashboard: 'Kokpit'
10
+ dashboard: 'Kokpit',
11
+ users: 'Użytkownicy'
11
12
  },
12
13
  collections: {
13
14
  title: 'Kolekcje'
@@ -32,7 +33,8 @@ export const sidebarLang = {
32
33
  main: {
33
34
  platform: 'Platform',
34
35
  media: 'Media Library',
35
- dashboard: 'Dashboard'
36
+ dashboard: 'Dashboard',
37
+ users: 'Users'
36
38
  },
37
39
  collections: {
38
40
  title: 'Collections'
@@ -5,14 +5,19 @@
5
5
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
6
6
  import CameraIcon from '@tabler/icons-svelte/icons/camera';
7
7
  import DashboardIcon from '@tabler/icons-svelte/icons/dashboard';
8
+ import UsersIcon from '@tabler/icons-svelte/icons/users';
8
9
  import SettingsIcon from '@tabler/icons-svelte/icons/settings';
9
10
  import { getRemotes } from '../../../sveltekit/index.js';
10
11
  import Skeleton from '../../../components/ui/skeleton/skeleton.svelte';
11
12
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
12
13
  import { page } from '$app/state';
14
+ import { authClient } from '../../auth-client.js';
13
15
 
14
16
  const interfaceLanguage = useInterfaceLanguage();
15
17
  const remotes = getRemotes();
18
+ let session = authClient.useSession();
19
+
20
+ const isAdmin = $derived(($session.data?.user as any)?.role === 'admin');
16
21
 
17
22
  let items: { title: string; url: string; icon?: Icon }[] = $derived([
18
23
  {
@@ -24,7 +29,16 @@
24
29
  title: sidebarLang[interfaceLanguage.current].main.media,
25
30
  url: '/admin/media',
26
31
  icon: CameraIcon
27
- }
32
+ },
33
+ ...(isAdmin
34
+ ? [
35
+ {
36
+ title: sidebarLang[interfaceLanguage.current].main.users,
37
+ url: '/admin/users',
38
+ icon: UsersIcon
39
+ }
40
+ ]
41
+ : [])
28
42
  ]);
29
43
 
30
44
  function isActive(url: string) {
@@ -0,0 +1,44 @@
1
+ import type { UserRole } from '../../types/roles.js';
2
+ export declare function createInvitation(email: string, role: UserRole, createdBy: string): Promise<{
3
+ createdAt: Date;
4
+ email: string;
5
+ id: string;
6
+ expiresAt: Date;
7
+ token: string;
8
+ role: string;
9
+ createdBy: string;
10
+ usedAt: Date | null;
11
+ }>;
12
+ export declare function getInvitationByToken(token: string): Promise<{
13
+ id: string;
14
+ email: string;
15
+ role: string;
16
+ token: string;
17
+ expiresAt: Date;
18
+ createdAt: Date;
19
+ createdBy: string;
20
+ usedAt: Date | null;
21
+ }>;
22
+ export declare function markInvitationUsed(id: string): Promise<void>;
23
+ export declare function getPendingInvitations(): Promise<{
24
+ id: string;
25
+ email: string;
26
+ role: string;
27
+ token: string;
28
+ expiresAt: Date;
29
+ createdAt: Date;
30
+ createdBy: string;
31
+ usedAt: Date | null;
32
+ }[]>;
33
+ export declare function getInvitationById(id: string): Promise<{
34
+ id: string;
35
+ email: string;
36
+ role: string;
37
+ token: string;
38
+ expiresAt: Date;
39
+ createdAt: Date;
40
+ createdBy: string;
41
+ usedAt: Date | null;
42
+ }>;
43
+ export declare function deleteInvitation(id: string): Promise<void>;
44
+ export declare function checkEmailExists(email: string): Promise<boolean>;