@svadmin/core 0.0.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ // TableState — Svelte 5 Class State Pattern for table data management
2
+ // Reactive class that encapsulates all table state with fine-grained $state/$derived
3
+
4
+ import { useParsed } from './useParsed';
5
+ import { useList } from './query-hooks.svelte';
6
+ import { readURLState, writeURLState } from './url-sync';
7
+ import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
8
+ import type { UseListOptions } from './query-hooks.svelte';
9
+
10
+ export type FilterSetMode = 'merge' | 'replace';
11
+
12
+ export interface TableStateOptions<TData extends BaseRecord = BaseRecord, TError = HttpError>
13
+ extends Omit<UseListOptions<TData, TError>, 'pagination' | 'sorters' | 'filters'> {
14
+ resource?: KnownResources;
15
+ pagination?: Pagination;
16
+ sorters?: {
17
+ initial?: Sort[];
18
+ permanent?: Sort[];
19
+ mode?: 'off' | 'server';
20
+ };
21
+ filters?: {
22
+ initial?: Filter[];
23
+ permanent?: Filter[];
24
+ mode?: 'off' | 'server';
25
+ defaultBehavior?: FilterSetMode;
26
+ };
27
+ syncWithLocation?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Svelte 5 Class-based reactive table state.
32
+ *
33
+ * Usage:
34
+ * ```ts
35
+ * const table = new TableState({ resource: 'posts' });
36
+ * table.setPage(2);
37
+ * table.toggleSort('title');
38
+ * console.log(table.data); // reactive
39
+ * ```
40
+ */
41
+ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpError> {
42
+ // ─── Reactive state ───────────────────────────────────────────
43
+ pagination = $state<Pagination>({ current: 1, pageSize: 10 });
44
+ currentSorters = $state<Sort[]>([]);
45
+ currentFilters = $state<Filter[]>([]);
46
+
47
+ // ─── Private config ───────────────────────────────────────────
48
+ private permanentSorters: Sort[];
49
+ private permanentFilters: Filter[];
50
+ private sortersMode: 'off' | 'server';
51
+ private filtersMode: 'off' | 'server';
52
+ private filterDefaultBehavior: FilterSetMode;
53
+ private syncWithLocation: boolean;
54
+ private paginationMode: string;
55
+
56
+ // ─── Query result ─────────────────────────────────────────────
57
+ private _queryResult;
58
+
59
+ constructor(options: TableStateOptions<TData, TError> = {}) {
60
+ const parsed = useParsed();
61
+ const resource = options.resource ?? parsed.resource ?? '';
62
+
63
+ this.permanentSorters = options.sorters?.permanent ?? [];
64
+ this.permanentFilters = options.filters?.permanent ?? [];
65
+ this.sortersMode = options.sorters?.mode ?? 'server';
66
+ this.filtersMode = options.filters?.mode ?? 'server';
67
+ this.filterDefaultBehavior = options.filters?.defaultBehavior ?? 'replace';
68
+ this.syncWithLocation = options.syncWithLocation ?? false;
69
+ this.paginationMode = options.pagination?.mode ?? 'server';
70
+
71
+ // Init from URL or defaults
72
+ let initPagination = options.pagination ?? { current: 1, pageSize: 10 };
73
+ let initSorters = options.sorters?.initial ?? [];
74
+ let initFilters = options.filters?.initial ?? [];
75
+
76
+ if (this.syncWithLocation && typeof window !== 'undefined') {
77
+ const urlState = readURLState();
78
+ if (urlState.page || urlState.pageSize) {
79
+ initPagination = {
80
+ current: urlState.page ?? initPagination.current,
81
+ pageSize: urlState.pageSize ?? initPagination.pageSize,
82
+ };
83
+ }
84
+ if (urlState.sortField) {
85
+ initSorters = [{ field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' }];
86
+ }
87
+ if (urlState.filters) {
88
+ initFilters = urlState.filters;
89
+ }
90
+ }
91
+
92
+ this.pagination = initPagination;
93
+ this.currentSorters = initSorters;
94
+ this.currentFilters = initFilters;
95
+
96
+ // Create query
97
+ const { meta, syncWithLocation: _s, dataProviderName, pagination: _p, sorters: _sr, filters: _f, ...restOptions } = options;
98
+ this._queryResult = useList<TData, TError>({
99
+ resource,
100
+ pagination: this.queryPagination,
101
+ sorters: this.effectiveSorters,
102
+ filters: this.effectiveFilters,
103
+ meta,
104
+ dataProviderName,
105
+ ...restOptions,
106
+ });
107
+
108
+ // URL sync effect
109
+ if (this.syncWithLocation && typeof window !== 'undefined') {
110
+ $effect(() => {
111
+ writeURLState({
112
+ page: this.pagination.current,
113
+ pageSize: this.pagination.pageSize,
114
+ sortField: this.effectiveSorters[0]?.field,
115
+ sortOrder: this.effectiveSorters[0]?.order,
116
+ filters: this.effectiveFilters,
117
+ });
118
+ });
119
+ }
120
+ }
121
+
122
+ // ─── Derived state ────────────────────────────────────────────
123
+ get effectiveSorters() { return [...this.currentSorters, ...this.permanentSorters]; }
124
+ get effectiveFilters() { return [...this.currentFilters, ...this.permanentFilters]; }
125
+
126
+ private get queryPagination() {
127
+ return this.paginationMode === 'server'
128
+ ? this.pagination
129
+ : { current: 1, pageSize: 999999, mode: this.paginationMode as 'client' | 'off' };
130
+ }
131
+
132
+ /** Underlying TanStack Query result */
133
+ get query() { return this._queryResult.query; }
134
+
135
+ /** Table data rows */
136
+ get data(): TData[] { return (this.query.data as GetListResult<TData> | undefined)?.data ?? []; }
137
+
138
+ /** Total record count */
139
+ get total(): number { return (this.query.data as GetListResult<TData> | undefined)?.total ?? 0; }
140
+
141
+ /** Is data loading */
142
+ get isLoading(): boolean { return this.query.isLoading; }
143
+
144
+ /** Is data error */
145
+ get isError(): boolean { return this.query.isError; }
146
+
147
+ /** Error object */
148
+ get error(): TError | null { return this.query.error; }
149
+
150
+ /** Current page number */
151
+ get current(): number { return this.pagination.current ?? 1; }
152
+
153
+ /** Page size */
154
+ get pageSize(): number { return this.pagination.pageSize ?? 10; }
155
+
156
+ /** Total number of pages */
157
+ get pageCount(): number { return Math.ceil(this.total / this.pageSize); }
158
+
159
+ /** Has next page */
160
+ get hasNextPage(): boolean { return this.current < this.pageCount; }
161
+
162
+ /** Has previous page */
163
+ get hasPreviousPage(): boolean { return this.current > 1; }
164
+
165
+ // ─── Mutators ─────────────────────────────────────────────────
166
+ setPage(page: number) {
167
+ this.pagination = { ...this.pagination, current: page };
168
+ }
169
+
170
+ setPageSize(size: number) {
171
+ this.pagination = { ...this.pagination, pageSize: size, current: 1 };
172
+ }
173
+
174
+ nextPage() {
175
+ if (this.hasNextPage) this.setPage(this.current + 1);
176
+ }
177
+
178
+ previousPage() {
179
+ if (this.hasPreviousPage) this.setPage(this.current - 1);
180
+ }
181
+
182
+ setSorters(newSorters: Sort[]) {
183
+ this.currentSorters = newSorters;
184
+ }
185
+
186
+ toggleSort(field: string) {
187
+ const existing = this.currentSorters.find(s => s.field === field);
188
+ if (!existing) {
189
+ this.currentSorters = [{ field, order: 'asc' }];
190
+ } else if (existing.order === 'asc') {
191
+ this.currentSorters = [{ field, order: 'desc' }];
192
+ } else {
193
+ this.currentSorters = [];
194
+ }
195
+ }
196
+
197
+ setFilters(newFilters: Filter[], mode?: FilterSetMode) {
198
+ const behavior = mode ?? this.filterDefaultBehavior;
199
+ if (behavior === 'merge') {
200
+ const merged = [...this.currentFilters];
201
+ for (const nf of newFilters) {
202
+ const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
203
+ if (idx >= 0) {
204
+ if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
205
+ else merged[idx] = nf;
206
+ } else if (nf.value !== undefined && nf.value !== null && nf.value !== '') {
207
+ merged.push(nf);
208
+ }
209
+ }
210
+ this.currentFilters = merged;
211
+ } else {
212
+ this.currentFilters = newFilters;
213
+ }
214
+ this.pagination = { ...this.pagination, current: 1 };
215
+ }
216
+
217
+ resetFilters() {
218
+ this.currentFilters = [];
219
+ this.pagination = { ...this.pagination, current: 1 };
220
+ }
221
+
222
+ /** For client-side pagination mode */
223
+ get clientData(): TData[] {
224
+ if (this.paginationMode !== 'client') return [];
225
+ const allData = this.data;
226
+ const start = (this.current - 1) * this.pageSize;
227
+ const end = start + this.pageSize;
228
+ let sorted = [...allData];
229
+ const activeSorters = this.sortersMode === 'off' ? this.effectiveSorters : this.currentSorters;
230
+ if (activeSorters.length > 0) {
231
+ const { field, order } = activeSorters[0];
232
+ sorted.sort((a, b) => {
233
+ const va = (a as Record<string, unknown>)[field];
234
+ const vb = (b as Record<string, unknown>)[field];
235
+ if (va == null) return 1;
236
+ if (vb == null) return -1;
237
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
238
+ return order === 'desc' ? -cmp : cmp;
239
+ });
240
+ }
241
+ return sorted.slice(start, end);
242
+ }
243
+ }
@@ -1,14 +1,20 @@
1
- // Toast notification system — Svelte 5 runes-based
1
+ // Toast notification system — wraps svelte-sonner for consistent API
2
+ // Maintains backward-compatible `toast.success()` / `toast.error()` etc.
2
3
 
3
- export type ToastType = 'success' | 'error' | 'info' | 'warning';
4
+ import { toast as sonner } from 'svelte-sonner';
5
+
6
+ export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
4
7
 
5
8
  export interface Toast {
6
9
  id: number;
7
10
  type: ToastType;
8
11
  message: string;
9
12
  duration: number;
13
+ onUndo?: () => void;
14
+ onTimeout?: () => void;
10
15
  }
11
16
 
17
+ // Legacy compatibility — kept for existing consumers
12
18
  let nextId = 0;
13
19
  let toasts = $state<Toast[]>([]);
14
20
 
@@ -16,22 +22,36 @@ export function getToasts(): Toast[] {
16
22
  return toasts;
17
23
  }
18
24
 
19
- export function addToast(type: ToastType, message: string, duration = 3000): void {
20
- const id = nextId++;
21
- toasts = [...toasts, { id, type, message, duration }];
22
- if (duration > 0) {
23
- setTimeout(() => removeToast(id), duration);
25
+ export function addToast(type: ToastType, message: string, duration = 3000, options?: { onUndo?: () => void, onTimeout?: () => void }): void {
26
+ // Use svelte-sonner for regular toasts
27
+ if (type !== 'undoable') {
28
+ switch (type) {
29
+ case 'success': sonner.success(message, { duration }); break;
30
+ case 'error': sonner.error(message, { duration }); break;
31
+ case 'warning': sonner.warning(message, { duration }); break;
32
+ case 'info': sonner.info(message, { duration }); break;
33
+ }
34
+ return;
24
35
  }
36
+
37
+ // Undoable toasts still use custom system
38
+ const id = nextId++;
39
+ toasts = [...toasts, { id, type, message, duration, ...options }];
25
40
  }
26
41
 
27
42
  export function removeToast(id: number): void {
28
43
  toasts = toasts.filter(t => t.id !== id);
29
44
  }
30
45
 
31
- // Convenience methods
46
+ // Convenience methods — backward compatible
32
47
  export const toast = {
33
48
  success: (msg: string, duration?: number) => addToast('success', msg, duration),
34
49
  error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
35
50
  info: (msg: string, duration?: number) => addToast('info', msg, duration),
36
51
  warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
52
+ undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
53
+ addToast('undoable', msg, duration, { onUndo, onTimeout }),
54
+ /** Promise toast — shows loading → success/error automatically */
55
+ promise: <T>(promise: Promise<T>, opts: { loading: string; success: string; error: string }) =>
56
+ sonner.promise(promise, opts),
37
57
  };
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,12 @@ 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
+ /** Navigation group name — resources with the same group are displayed in a collapsible section */
259
+ group?: string;
260
+ meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
214
261
  }
215
262
 
216
263
  export interface FieldDefinition {
@@ -221,6 +268,7 @@ export interface FieldDefinition {
221
268
  required?: boolean;
222
269
  searchable?: boolean;
223
270
  sortable?: boolean;
271
+ filterable?: boolean;
224
272
  width?: string;
225
273
  showInList?: boolean;
226
274
  showInForm?: boolean;
@@ -235,4 +283,38 @@ export interface FieldDefinition {
235
283
  optionValue?: string; // field to use as value
236
284
  // Validation
237
285
  validate?: (value: unknown) => string | null;
286
+ // Grouping
287
+ group?: string;
238
288
  }
289
+
290
+ // ─── Resource Type Registry ───────────────────────────────────
291
+
292
+ /**
293
+ * Extend this interface via declaration merging to register resource types.
294
+ * When registered, all hooks automatically infer data types from resource names.
295
+ *
296
+ * @example
297
+ * ```ts
298
+ * declare module '@svadmin/core' {
299
+ * interface ResourceTypeMap {
300
+ * users: { id: string; name: string; email: string }
301
+ * posts: { id: string; title: string; content: string }
302
+ * }
303
+ * }
304
+ *
305
+ * // Now hooks auto-infer:
306
+ * const list = useList({ resource: 'users' })
307
+ * // list.data → { id: string; name: string; email: string }[]
308
+ * ```
309
+ */
310
+ export interface ResourceTypeMap {}
311
+
312
+ /** When ResourceTypeMap is empty → string (backward compatible); otherwise → registered keys */
313
+ export type KnownResources = keyof ResourceTypeMap extends never
314
+ ? string
315
+ : Extract<keyof ResourceTypeMap, string>
316
+
317
+ /** Infer data type from resource name. Falls back to Record<string, unknown> for unregistered resources */
318
+ export type InferData<R extends string> = R extends keyof ResourceTypeMap
319
+ ? ResourceTypeMap[R]
320
+ : 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
+ // No lifecycle imports needed — uses module-level $state pattern
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
+ }