@svadmin/core 0.0.3 → 0.3.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 (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -0,0 +1,178 @@
1
+ import { useParsed } from './useParsed';
2
+ import { useList } from './query-hooks.svelte';
3
+ import { readURLState, writeURLState } from './url-sync';
4
+ import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources } from './types';
5
+ import type { UseListOptions } from './query-hooks.svelte';
6
+
7
+ export type FilterSetMode = 'merge' | 'replace';
8
+
9
+ export interface UseTableOptions<TQueryFnData extends BaseRecord = BaseRecord, TError = HttpError, TSearchVariables = Record<string, unknown>> extends Omit<UseListOptions<TQueryFnData, TError>, 'pagination' | 'sorters' | 'filters'> {
10
+ resource?: KnownResources;
11
+ pagination?: Pagination;
12
+ sorters?: {
13
+ initial?: Sort[];
14
+ permanent?: Sort[];
15
+ mode?: 'off' | 'server';
16
+ };
17
+ filters?: {
18
+ initial?: Filter[];
19
+ permanent?: Filter[];
20
+ mode?: 'off' | 'server';
21
+ defaultBehavior?: FilterSetMode;
22
+ };
23
+ syncWithLocation?: boolean;
24
+ }
25
+
26
+ export function useTable<
27
+ TQueryFnData extends BaseRecord = BaseRecord,
28
+ TError = HttpError,
29
+ TSearchVariables = Record<string, unknown>
30
+ >(options: UseTableOptions<TQueryFnData, TError, TSearchVariables> = {}) {
31
+ const parsed = useParsed();
32
+ const resource = options.resource ?? parsed.resource ?? '';
33
+ const { meta, syncWithLocation = false, dataProviderName, pagination: _p, sorters: _s, filters: _f, ...restOptions } = options;
34
+
35
+ const paginationMode = options.pagination?.mode ?? 'server';
36
+ const sortersMode = options.sorters?.mode ?? 'server';
37
+ const filtersMode = options.filters?.mode ?? 'server';
38
+ const filterDefaultBehavior = options.filters?.defaultBehavior ?? 'replace';
39
+
40
+ const permanentSorters = options.sorters?.permanent ?? [];
41
+ const permanentFilters = options.filters?.permanent ?? [];
42
+
43
+ let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
44
+ let initSorters = options.sorters?.initial ?? [];
45
+ let initFilters = options.filters?.initial ?? [];
46
+
47
+ if (syncWithLocation && typeof window !== 'undefined') {
48
+ const urlState = readURLState();
49
+ if (urlState.page || urlState.pageSize) {
50
+ initPagination = {
51
+ current: urlState.page ?? initPagination.current,
52
+ pageSize: urlState.pageSize ?? initPagination.pageSize,
53
+ };
54
+ }
55
+ if (urlState.sortField) {
56
+ initSorters = [{ field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' }];
57
+ }
58
+ if (urlState.filters) {
59
+ initFilters = urlState.filters;
60
+ }
61
+ }
62
+
63
+ let pagination = $state<Pagination>(initPagination);
64
+ let currentSorters = $state<Sort[]>(initSorters);
65
+ let currentFilters = $state<Filter[]>(initFilters);
66
+
67
+ const effectiveSorters = $derived([...currentSorters, ...permanentSorters]);
68
+ const effectiveFilters = $derived([...currentFilters, ...permanentFilters]);
69
+
70
+ const querySorters = $derived(sortersMode === 'server' ? effectiveSorters : []);
71
+ const queryFilters = $derived(filtersMode === 'server' ? effectiveFilters : []);
72
+ const queryPagination = paginationMode === 'server'
73
+ ? pagination
74
+ : { current: 1, pageSize: 999999, mode: paginationMode as 'client' | 'off' };
75
+
76
+ const tableQueryInfo = useList<TQueryFnData, TError>({
77
+ resource,
78
+ pagination: queryPagination,
79
+ sorters: querySorters,
80
+ filters: queryFilters,
81
+ meta,
82
+ dataProviderName,
83
+ ...restOptions
84
+ });
85
+
86
+ const query = tableQueryInfo.query;
87
+ const overtime = tableQueryInfo.overtime;
88
+
89
+ function setSorters(newSorters: Sort[]) { currentSorters = newSorters; }
90
+ function setFilters(newFilters: Filter[], mode?: FilterSetMode) {
91
+ const behavior = mode ?? filterDefaultBehavior;
92
+ if (behavior === 'merge') {
93
+ const merged = [...currentFilters];
94
+ for (const nf of newFilters) {
95
+ const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
96
+ if (idx >= 0) {
97
+ if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
98
+ else merged[idx] = nf;
99
+ } else if (nf.value !== undefined && nf.value !== null && nf.value !== '') {
100
+ merged.push(nf);
101
+ }
102
+ }
103
+ currentFilters = merged;
104
+ } else {
105
+ currentFilters = newFilters;
106
+ }
107
+ pagination = { ...pagination, current: 1 };
108
+ }
109
+
110
+ function setPage(page: number) { pagination = { ...pagination, current: page }; }
111
+ function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
112
+
113
+ if (syncWithLocation && typeof window !== 'undefined') {
114
+ $effect(() => {
115
+ writeURLState({
116
+ page: pagination.current,
117
+ pageSize: pagination.pageSize,
118
+ sortField: effectiveSorters[0]?.field,
119
+ sortOrder: effectiveSorters[0]?.order,
120
+ filters: effectiveFilters,
121
+ });
122
+ });
123
+ }
124
+
125
+ function createLinkForSyncWithLocation(): string {
126
+ const params = new URLSearchParams();
127
+ if (pagination.current) params.set('page', String(pagination.current));
128
+ if (pagination.pageSize) params.set('pageSize', String(pagination.pageSize));
129
+ if (effectiveSorters[0]) {
130
+ params.set('sortField', effectiveSorters[0].field);
131
+ params.set('sortOrder', effectiveSorters[0].order);
132
+ }
133
+ if (effectiveFilters.length > 0) {
134
+ params.set('filters', JSON.stringify(effectiveFilters));
135
+ }
136
+ return `?${params.toString()}`;
137
+ }
138
+
139
+ return {
140
+ query,
141
+ tableQuery: query,
142
+ get overtime() { return overtime; },
143
+ get pagination() { return pagination; },
144
+ get sorters() { return currentSorters; },
145
+ get filters() { return currentFilters; },
146
+ setSorters, setFilters, setPage, setPageSize,
147
+ setCurrentPage: setPage,
148
+ createLinkForSyncWithLocation,
149
+ get totalPages() {
150
+ // ts-expect-error reactive access
151
+ const total = (query as any).data?.total ?? 0;
152
+ return Math.ceil(total / (pagination.pageSize ?? 10));
153
+ },
154
+ get current() { return pagination.current ?? 1; },
155
+ get pageSize() { return pagination.pageSize ?? 10; },
156
+ get pageCount() { return Math.ceil(((query as any).data?.total ?? 0) / (pagination.pageSize ?? 10)); },
157
+ get clientData(): TQueryFnData[] {
158
+ if (paginationMode !== 'client') return [];
159
+ const allData = ((query as any).data?.data ?? []) as TQueryFnData[];
160
+ const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
161
+ const end = start + (pagination.pageSize ?? 10);
162
+ let sorted = [...allData];
163
+ const activeSorters = sortersMode === 'off' ? effectiveSorters : currentSorters;
164
+ if (activeSorters.length > 0) {
165
+ const { field, order } = activeSorters[0];
166
+ sorted.sort((a, b) => {
167
+ const va = (a as Record<string, unknown>)[field];
168
+ const vb = (b as Record<string, unknown>)[field];
169
+ if (va == null) return 1;
170
+ if (vb == null) return -1;
171
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
172
+ return order === 'desc' ? -cmp : cmp;
173
+ });
174
+ }
175
+ return sorted.slice(start, end);
176
+ },
177
+ };
178
+ }
@@ -1,12 +1,14 @@
1
1
  // Toast notification system — Svelte 5 runes-based
2
2
 
3
- export type ToastType = 'success' | 'error' | 'info' | 'warning';
3
+ export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
4
4
 
5
5
  export interface Toast {
6
6
  id: number;
7
7
  type: ToastType;
8
8
  message: string;
9
9
  duration: number;
10
+ onUndo?: () => void;
11
+ onTimeout?: () => void;
10
12
  }
11
13
 
12
14
  let nextId = 0;
@@ -16,10 +18,10 @@ export function getToasts(): Toast[] {
16
18
  return toasts;
17
19
  }
18
20
 
19
- export function addToast(type: ToastType, message: string, duration = 3000): void {
21
+ export function addToast(type: ToastType, message: string, duration = 3000, options?: { onUndo?: () => void, onTimeout?: () => void }): void {
20
22
  const id = nextId++;
21
- toasts = [...toasts, { id, type, message, duration }];
22
- if (duration > 0) {
23
+ toasts = [...toasts, { id, type, message, duration, ...options }];
24
+ if (duration > 0 && type !== 'undoable') {
23
25
  setTimeout(() => removeToast(id), duration);
24
26
  }
25
27
  }
@@ -34,4 +36,6 @@ export const toast = {
34
36
  error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
35
37
  info: (msg: string, duration?: number) => addToast('info', msg, duration),
36
38
  warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
39
+ undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
40
+ addToast('undoable', msg, duration, { onUndo, onTimeout }),
37
41
  };
package/src/types.ts CHANGED
@@ -1,4 +1,31 @@
1
- // Core type definitions — 100% Refine-compatible DataProvider + AuthProvider + Providers
1
+ // Core type definitions — DataProvider + AuthProvider + Providers
2
+
3
+ // ─── HttpError ─────────────────────────────────────────────────
4
+
5
+ export type ValidationErrors = Record<string, string | string[]>;
6
+
7
+ export class HttpError extends Error {
8
+ statusCode: number;
9
+ errors?: ValidationErrors;
10
+
11
+ constructor(message: string, statusCode: number, errors?: ValidationErrors) {
12
+ super(message);
13
+ this.name = 'HttpError';
14
+ this.statusCode = statusCode;
15
+ this.errors = errors;
16
+ }
17
+ }
18
+
19
+ export class UndoError extends Error {
20
+ constructor() {
21
+ super('Mutation undone');
22
+ this.name = 'UndoError';
23
+ }
24
+ }
25
+
26
+ // ─── Base Types ───────────────────────────────────────────────
27
+
28
+ export type BaseRecord = Record<string, unknown>;
2
29
 
3
30
  // ─── DataProvider ─────────────────────────────────────────────
4
31
 
@@ -13,12 +40,25 @@ export interface Sort {
13
40
  order: 'asc' | 'desc';
14
41
  }
15
42
 
43
+ export type CrudOperator =
44
+ | 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte'
45
+ | 'contains' | 'ncontains'
46
+ | 'startswith' | 'endswith'
47
+ | 'in' | 'nin'
48
+ | 'null' | 'nnull'
49
+ | 'between' | 'nbetween';
50
+
16
51
  export interface Filter {
17
52
  field: string;
18
- operator: 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte' | 'contains' | 'in' | 'null' | 'between';
53
+ operator: CrudOperator;
19
54
  value: unknown;
20
55
  }
21
56
 
57
+ export interface LogicalFilter {
58
+ operator: 'or' | 'and';
59
+ value: Filter[];
60
+ }
61
+
22
62
  export interface GetListParams {
23
63
  resource: string;
24
64
  pagination?: Pagination;
@@ -27,8 +67,8 @@ export interface GetListParams {
27
67
  meta?: Record<string, unknown>;
28
68
  }
29
69
 
30
- export interface GetListResult<T = Record<string, unknown>> {
31
- data: T[];
70
+ export interface GetListResult<TData extends BaseRecord = BaseRecord> {
71
+ data: TData[];
32
72
  total: number;
33
73
  }
34
74
 
@@ -38,8 +78,8 @@ export interface GetOneParams {
38
78
  meta?: Record<string, unknown>;
39
79
  }
40
80
 
41
- export interface GetOneResult<T = Record<string, unknown>> {
42
- data: T;
81
+ export interface GetOneResult<TData extends BaseRecord = BaseRecord> {
82
+ data: TData;
43
83
  }
44
84
 
45
85
  export interface GetManyParams {
@@ -48,78 +88,78 @@ export interface GetManyParams {
48
88
  meta?: Record<string, unknown>;
49
89
  }
50
90
 
51
- export interface GetManyResult<T = Record<string, unknown>> {
52
- data: T[];
91
+ export interface GetManyResult<TData extends BaseRecord = BaseRecord> {
92
+ data: TData[];
53
93
  }
54
94
 
55
- export interface CreateParams {
95
+ export interface CreateParams<TVariables = unknown> {
56
96
  resource: string;
57
- variables: Record<string, unknown>;
97
+ variables: TVariables;
58
98
  meta?: Record<string, unknown>;
59
99
  }
60
100
 
61
- export interface CreateResult<T = Record<string, unknown>> {
62
- data: T;
101
+ export interface CreateResult<TData extends BaseRecord = BaseRecord> {
102
+ data: TData;
63
103
  }
64
104
 
65
- export interface CreateManyParams {
105
+ export interface CreateManyParams<TVariables = unknown> {
66
106
  resource: string;
67
- variables: Record<string, unknown>[];
107
+ variables: TVariables[];
68
108
  meta?: Record<string, unknown>;
69
109
  }
70
110
 
71
- export interface CreateManyResult<T = Record<string, unknown>> {
72
- data: T[];
111
+ export interface CreateManyResult<TData extends BaseRecord = BaseRecord> {
112
+ data: TData[];
73
113
  }
74
114
 
75
- export interface UpdateParams {
115
+ export interface UpdateParams<TVariables = unknown> {
76
116
  resource: string;
77
117
  id: string | number;
78
- variables: Record<string, unknown>;
118
+ variables: TVariables;
79
119
  meta?: Record<string, unknown>;
80
120
  }
81
121
 
82
- export interface UpdateResult<T = Record<string, unknown>> {
83
- data: T;
122
+ export interface UpdateResult<TData extends BaseRecord = BaseRecord> {
123
+ data: TData;
84
124
  }
85
125
 
86
- export interface UpdateManyParams {
126
+ export interface UpdateManyParams<TVariables = unknown> {
87
127
  resource: string;
88
128
  ids: (string | number)[];
89
- variables: Record<string, unknown>;
129
+ variables: TVariables;
90
130
  meta?: Record<string, unknown>;
91
131
  }
92
132
 
93
- export interface UpdateManyResult<T = Record<string, unknown>> {
94
- data: T[];
133
+ export interface UpdateManyResult<TData extends BaseRecord = BaseRecord> {
134
+ data: TData[];
95
135
  }
96
136
 
97
- export interface DeleteParams {
137
+ export interface DeleteParams<TVariables = unknown> {
98
138
  resource: string;
99
139
  id: string | number;
100
- variables?: Record<string, unknown>;
140
+ variables?: TVariables;
101
141
  meta?: Record<string, unknown>;
102
142
  }
103
143
 
104
- export interface DeleteResult<T = Record<string, unknown>> {
105
- data: T;
144
+ export interface DeleteResult<TData extends BaseRecord = BaseRecord> {
145
+ data: TData;
106
146
  }
107
147
 
108
- export interface DeleteManyParams {
148
+ export interface DeleteManyParams<TVariables = unknown> {
109
149
  resource: string;
110
150
  ids: (string | number)[];
111
- variables?: Record<string, unknown>;
151
+ variables?: TVariables;
112
152
  meta?: Record<string, unknown>;
113
153
  }
114
154
 
115
- export interface DeleteManyResult<T = Record<string, unknown>> {
116
- data: T[];
155
+ export interface DeleteManyResult<TData extends BaseRecord = BaseRecord> {
156
+ data: TData[];
117
157
  }
118
158
 
119
- export interface CustomParams {
159
+ export interface CustomParams<TVariables = unknown> {
120
160
  url: string;
121
161
  method: 'get' | 'post' | 'put' | 'patch' | 'delete';
122
- payload?: Record<string, unknown>;
162
+ payload?: TVariables;
123
163
  query?: Record<string, unknown>;
124
164
  headers?: Record<string, string>;
125
165
  sorters?: Sort[];
@@ -127,27 +167,27 @@ export interface CustomParams {
127
167
  meta?: Record<string, unknown>;
128
168
  }
129
169
 
130
- export interface CustomResult<T = unknown> {
131
- data: T;
170
+ export interface CustomResult<TData = unknown> {
171
+ data: TData;
132
172
  }
133
173
 
134
174
  export interface DataProvider {
135
175
  // Required methods
136
- getList: <T = Record<string, unknown>>(params: GetListParams) => Promise<GetListResult<T>>;
137
- getOne: <T = Record<string, unknown>>(params: GetOneParams) => Promise<GetOneResult<T>>;
138
- create: <T = Record<string, unknown>>(params: CreateParams) => Promise<CreateResult<T>>;
139
- update: <T = Record<string, unknown>>(params: UpdateParams) => Promise<UpdateResult<T>>;
140
- deleteOne: <T = Record<string, unknown>>(params: DeleteParams) => Promise<DeleteResult<T>>;
176
+ getList: <TData extends BaseRecord = BaseRecord>(params: GetListParams) => Promise<GetListResult<TData>>;
177
+ getOne: <TData extends BaseRecord = BaseRecord>(params: GetOneParams) => Promise<GetOneResult<TData>>;
178
+ create: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateParams<TVariables>) => Promise<CreateResult<TData>>;
179
+ update: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateParams<TVariables>) => Promise<UpdateResult<TData>>;
180
+ deleteOne: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteParams<TVariables>) => Promise<DeleteResult<TData>>;
141
181
  getApiUrl: () => string;
142
182
 
143
183
  // Optional bulk methods
144
- getMany?: <T = Record<string, unknown>>(params: GetManyParams) => Promise<GetManyResult<T>>;
145
- createMany?: <T = Record<string, unknown>>(params: CreateManyParams) => Promise<CreateManyResult<T>>;
146
- updateMany?: <T = Record<string, unknown>>(params: UpdateManyParams) => Promise<UpdateManyResult<T>>;
147
- deleteMany?: <T = Record<string, unknown>>(params: DeleteManyParams) => Promise<DeleteManyResult<T>>;
184
+ getMany?: <TData extends BaseRecord = BaseRecord>(params: GetManyParams) => Promise<GetManyResult<TData>>;
185
+ createMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateManyParams<TVariables>) => Promise<CreateManyResult<TData>>;
186
+ updateMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateManyParams<TVariables>) => Promise<UpdateManyResult<TData>>;
187
+ deleteMany?: <TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteManyParams<TVariables>) => Promise<DeleteManyResult<TData>>;
148
188
 
149
189
  // Optional custom method
150
- custom?: <T = unknown>(params: CustomParams) => Promise<CustomResult<T>>;
190
+ custom?: <TData = unknown, TVariables = unknown>(params: CustomParams<TVariables>) => Promise<CustomResult<TData>>;
151
191
  }
152
192
 
153
193
  // ─── AuthProvider ─────────────────────────────────────────────
@@ -200,6 +240,8 @@ export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable';
200
240
 
201
241
  export interface ResourceDefinition {
202
242
  name: string;
243
+ /** Unique identifier — use when multiple resources share the same `name` but target different DataProviders */
244
+ identifier?: string;
203
245
  label: string;
204
246
  icon?: string;
205
247
  primaryKey?: string;
@@ -210,7 +252,10 @@ export interface ResourceDefinition {
210
252
  canEdit?: boolean;
211
253
  canDelete?: boolean;
212
254
  canShow?: boolean;
213
- meta?: Record<string, unknown>;
255
+ showInMenu?: boolean;
256
+ parentName?: string;
257
+ menuOrder?: number;
258
+ meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
214
259
  }
215
260
 
216
261
  export interface FieldDefinition {
@@ -236,3 +281,35 @@ export interface FieldDefinition {
236
281
  // Validation
237
282
  validate?: (value: unknown) => string | null;
238
283
  }
284
+
285
+ // ─── Resource Type Registry ───────────────────────────────────
286
+
287
+ /**
288
+ * Extend this interface via declaration merging to register resource types.
289
+ * When registered, all hooks automatically infer data types from resource names.
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * declare module '@svadmin/core' {
294
+ * interface ResourceTypeMap {
295
+ * users: { id: string; name: string; email: string }
296
+ * posts: { id: string; title: string; content: string }
297
+ * }
298
+ * }
299
+ *
300
+ * // Now hooks auto-infer:
301
+ * const list = useList({ resource: 'users' })
302
+ * // list.data → { id: string; name: string; email: string }[]
303
+ * ```
304
+ */
305
+ export interface ResourceTypeMap {}
306
+
307
+ /** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
308
+ export type KnownResources = keyof ResourceTypeMap extends never
309
+ ? string
310
+ : Extract<keyof ResourceTypeMap, string>
311
+
312
+ /** Infer data type from resource name. Falls back to Record<string, unknown> for unregistered resources */
313
+ export type InferData<R extends string> = R extends keyof ResourceTypeMap
314
+ ? ResourceTypeMap[R]
315
+ : Record<string, unknown>
@@ -0,0 +1,62 @@
1
+ // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
+ // Svelte 5 component using runes
3
+
4
+ import { onDestroy, onMount } from 'svelte';
5
+
6
+ let warnWhenUnsavedChanges = $state(false);
7
+
8
+ /**
9
+ * Call this to mark the form as dirty (has unsaved changes).
10
+ * When set to true, navigating away or closing the tab will trigger a warning.
11
+ */
12
+ export function setUnsavedChanges(dirty: boolean) {
13
+ warnWhenUnsavedChanges = dirty;
14
+ }
15
+
16
+ export function getUnsavedChanges(): boolean {
17
+ return warnWhenUnsavedChanges;
18
+ }
19
+
20
+ const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
21
+ if (warnWhenUnsavedChanges) {
22
+ e.preventDefault();
23
+ // Modern browsers ignore custom messages but require returnValue to be set
24
+ e.returnValue = '';
25
+ return '';
26
+ }
27
+ };
28
+
29
+ /**
30
+ * Initialize the UnsavedChangesNotifier.
31
+ * Call this in your root layout or AdminApp component.
32
+ * It handles both browser tab close and in-app navigation.
33
+ */
34
+ export function initUnsavedChangesNotifier() {
35
+ if (typeof window === 'undefined') return;
36
+
37
+ window.addEventListener('beforeunload', beforeUnloadHandler);
38
+
39
+ // For hash-based navigation, intercept hashchange
40
+ const hashChangeHandler = (e: HashChangeEvent) => {
41
+ if (warnWhenUnsavedChanges) {
42
+ const confirmed = confirm('You have unsaved changes. Are you sure you want to leave?');
43
+ if (!confirmed) {
44
+ e.preventDefault();
45
+ // Restore original hash
46
+ if (e.oldURL) {
47
+ const oldHash = new URL(e.oldURL).hash;
48
+ history.replaceState(null, '', oldHash || '#');
49
+ }
50
+ } else {
51
+ warnWhenUnsavedChanges = false;
52
+ }
53
+ }
54
+ };
55
+
56
+ window.addEventListener('hashchange', hashChangeHandler);
57
+
58
+ return () => {
59
+ window.removeEventListener('beforeunload', beforeUnloadHandler);
60
+ window.removeEventListener('hashchange', hashChangeHandler);
61
+ };
62
+ }
package/src/url-sync.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // URL state sync — sync pagination/sort/filters with hash params
2
2
 
3
+ import type { Filter } from './types';
4
+
3
5
  export interface URLState {
4
6
  page?: number;
5
7
  pageSize?: number;
6
8
  sortField?: string;
7
9
  sortOrder?: 'asc' | 'desc';
8
10
  search?: string;
11
+ filters?: Filter[];
9
12
  }
10
13
 
11
14
  export function readURLState(): URLState {
15
+ if (typeof window === 'undefined') return {};
12
16
  const hash = window.location.hash;
13
17
  const queryIdx = hash.indexOf('?');
14
18
  if (queryIdx === -1) return {};
@@ -31,10 +35,18 @@ export function readURLState(): URLState {
31
35
  const search = params.get('q');
32
36
  if (search) state.search = search;
33
37
 
38
+ const filtersRaw = params.get('filters');
39
+ if (filtersRaw) {
40
+ try {
41
+ state.filters = JSON.parse(filtersRaw);
42
+ } catch { /* ignore invalid json */ }
43
+ }
44
+
34
45
  return state;
35
46
  }
36
47
 
37
48
  export function writeURLState(state: URLState): void {
49
+ if (typeof window === 'undefined') return;
38
50
  const hash = window.location.hash;
39
51
  const pathIdx = hash.indexOf('?');
40
52
  const basePath = pathIdx === -1 ? hash : hash.slice(0, pathIdx);
@@ -45,6 +57,9 @@ export function writeURLState(state: URLState): void {
45
57
  if (state.sortField) params.set('sort', state.sortField);
46
58
  if (state.sortOrder) params.set('order', state.sortOrder);
47
59
  if (state.search) params.set('q', state.search);
60
+ if (state.filters && state.filters.length > 0) {
61
+ params.set('filters', JSON.stringify(state.filters));
62
+ }
48
63
 
49
64
  const qs = params.toString();
50
65
  const newHash = qs ? `${basePath}?${qs}` : basePath;