@svadmin/core 0.20.3 → 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.20.3",
3
+ "version": "0.21.1",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -123,7 +123,9 @@ export function useGetIdentity() {
123
123
  });
124
124
  }
125
125
 
126
- fetch();
126
+ if (typeof window !== 'undefined') {
127
+ fetch();
128
+ }
127
129
 
128
130
  return {
129
131
  get data() { return data; },
@@ -141,13 +143,19 @@ export function useIsAuthenticated() {
141
143
  let isLoading = $state(true);
142
144
 
143
145
  if (provider) {
144
- provider.check().then((result: CheckResult) => {
145
- isAuthenticated = result.authenticated;
146
- isLoading = false;
147
- }).catch(() => {
146
+ if (typeof window !== 'undefined') {
147
+ provider.check().then((result: CheckResult) => {
148
+ isAuthenticated = result.authenticated;
149
+ isLoading = false;
150
+ }).catch(() => {
151
+ isAuthenticated = false;
152
+ isLoading = false;
153
+ });
154
+ } else {
155
+ // In SSR we assume false to prevent hydration mismatch before check
148
156
  isAuthenticated = false;
149
157
  isLoading = false;
150
- });
158
+ }
151
159
  } else {
152
160
  // No auth provider — treat as authenticated
153
161
  isAuthenticated = true;
@@ -230,7 +238,9 @@ export function usePermissions<T = unknown>() {
230
238
  });
231
239
  }
232
240
 
233
- fetch();
241
+ if (typeof window !== 'undefined') {
242
+ fetch();
243
+ }
234
244
 
235
245
  return {
236
246
  get isLoading() { return isLoading; },
@@ -144,8 +144,9 @@ export function useForm<
144
144
  const action = options.action ?? (parsed.action === 'list' ? 'create' : parsed.action as 'create' | 'edit' | 'clone') ?? 'create';
145
145
  let currentId = $state<string | number | undefined>(options.id ?? parsed.id);
146
146
 
147
+ const defaultRedirectOpt = action === 'clone' ? adminOptions.redirect?.afterClone : action === 'edit' ? adminOptions.redirect?.afterEdit : adminOptions.redirect?.afterCreate;
147
148
  const {
148
- redirect: redirectDefault = 'list',
149
+ redirect: redirectDefault = defaultRedirectOpt ?? 'list',
149
150
  successNotification, errorNotification,
150
151
  onMutationSuccess, onMutationError,
151
152
  meta: hookMeta, queryMeta: hookQueryMeta, mutationMeta: hookMutationMeta,
@@ -252,7 +253,7 @@ export function useForm<
252
253
  }
253
254
 
254
255
  // ─── Query (edit/clone mode) ────────────────────────────────────
255
- const query = (action === 'edit' || action === 'clone') && currentId != null
256
+ const query = (action === 'edit' || action === 'clone')
256
257
  ? createQuery(() => ({
257
258
  queryKey: [resource, 'one', currentId],
258
259
  queryFn: async () => {
@@ -265,17 +266,21 @@ export function useForm<
265
266
  : null;
266
267
 
267
268
  // Auto-populate values from query data
268
- let queryInitialized = false;
269
+ let queryInitializedId: string | number | undefined = undefined;
269
270
  if (query) {
270
271
  $effect.pre(() => {
271
- if (queryInitialized) return;
272
+ if (queryInitializedId === currentId) return;
272
273
  const data = query.data as Record<string, unknown> | undefined;
273
- if (data) {
274
+ if (data && (currentId == null || String(data.id) === String(currentId) || !data.id)) {
275
+ // In clone mode, strip out identifiers
276
+ const targetData = action === 'clone'
277
+ ? Object.fromEntries(Object.entries(data).filter(([k]) => k !== 'id' && k !== '_id'))
278
+ : data;
274
279
  // Merge: defaultValues < query data
275
- const merged = { ...(options.defaultValues ?? {}), ...data } as TVariables;
280
+ const merged = { ...(options.defaultValues ?? {}), ...targetData } as TVariables;
276
281
  values = merged;
277
282
  initialValues = { ...merged } as TVariables;
278
- queryInitialized = true;
283
+ queryInitializedId = currentId;
279
284
  }
280
285
  });
281
286
  }
@@ -291,7 +296,11 @@ export function useForm<
291
296
  if (successNotification !== false) notify({ type: 'success', message: typeof successNotification === 'string' ? successNotification : t('common.createSuccess') });
292
297
  audit({ action: 'create', resource, recordId: String((data.data as Record<string, unknown>).id) });
293
298
  onMutationSuccess?.(data);
294
- if (redirectOverride !== false) doRedirect(redirectOverride ?? redirectDefault);
299
+ if (redirectOverride !== false) {
300
+ const newId = (data.data as Record<string, unknown>).id;
301
+ if (newId != null) currentId = newId as string | number;
302
+ doRedirect(redirectOverride ?? redirectDefault);
303
+ }
295
304
  },
296
305
  onError: (error: Error) => { handleHttpError(error); onMutationError?.(error); },
297
306
  }));
@@ -344,7 +353,7 @@ export function useForm<
344
353
  let lastAutoSaveError = $state<unknown>(null);
345
354
 
346
355
  function triggerAutoSave() {
347
- if (!autoSaveOpts?.enabled || action === 'create') return;
356
+ if (!autoSaveOpts?.enabled || action === 'create' || action === 'clone') return;
348
357
  if (autoSaveTimer) clearTimeout(autoSaveTimer);
349
358
 
350
359
  autoSaveTimer = setTimeout(async () => {
@@ -328,7 +328,12 @@ export function useInvalidate() {
328
328
  queryClient.invalidateQueries();
329
329
  return;
330
330
  }
331
- queryClient.invalidateQueries({ queryKey: [params.resource] });
331
+ const scopes = params.invalidates || ['resourceAll'];
332
+ for (const scope of scopes) {
333
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [params.resource] });
334
+ else if (scope === 'detail' && params.id) queryClient.invalidateQueries({ queryKey: [params.resource, 'one', params.id] });
335
+ else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [params.resource, 'list'] });
336
+ }
332
337
  };
333
338
  }
334
339
 
@@ -46,6 +46,7 @@ const locales: Record<string, Record<string, string>> = {
46
46
  'common.darkMode': '暗色模式',
47
47
  'common.lightMode': '亮色模式',
48
48
  'common.detail': '详情',
49
+ 'common.show': '查看',
49
50
  'common.loadFailed': '加载失败: {message}',
50
51
  'common.pageNotFound': '页面未找到',
51
52
  'common.error': '出错了',
@@ -283,6 +284,7 @@ const locales: Record<string, Record<string, string>> = {
283
284
  'common.darkMode': 'Dark mode',
284
285
  'common.lightMode': 'Light mode',
285
286
  'common.detail': 'Detail',
287
+ 'common.show': 'Show',
286
288
  'common.loadFailed': 'Load failed: {message}',
287
289
  'common.pageNotFound': 'Page not found',
288
290
  'common.error': 'Something went wrong',
@@ -513,7 +515,7 @@ export function t(key: string, params?: Record<string, string | number>): string
513
515
 
514
516
  if (params) {
515
517
  for (const [k, v] of Object.entries(params)) {
516
- text = text.replace(`{${k}}`, String(v));
518
+ text = text.replaceAll(`{${k}}`, String(v));
517
519
  }
518
520
  }
519
521
  return text;
package/src/live-sse.ts CHANGED
@@ -29,6 +29,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
29
29
  const subscribers = new Map<string, Set<Callback>>();
30
30
  let eventSource: EventSource | null = null;
31
31
  let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
32
+ const registeredListeners = new Set<string>();
32
33
 
33
34
  function connect() {
34
35
  if (eventSource) return;
@@ -64,7 +65,8 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
64
65
  }
65
66
 
66
67
  function addNamedListener(resource: string) {
67
- if (!eventSource) return;
68
+ if (!eventSource || registeredListeners.has(resource)) return;
69
+ registeredListeners.add(resource);
68
70
  eventSource.addEventListener(resource, ((event: MessageEvent) => {
69
71
  try {
70
72
  const parsed = JSON.parse(event.data);
@@ -109,12 +111,15 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
109
111
  eventSource = null;
110
112
  }
111
113
  status = 'disconnected';
114
+ registeredListeners.clear();
112
115
  }
113
116
 
114
117
  return {
115
118
  subscribe({ resource, callback }) {
119
+ let isNewResource = false;
116
120
  if (!subscribers.has(resource)) {
117
121
  subscribers.set(resource, new Set());
122
+ isNewResource = true;
118
123
  }
119
124
  subscribers.get(resource)!.add(callback);
120
125
 
@@ -123,7 +128,7 @@ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProv
123
128
  connect();
124
129
  }
125
130
  // Add named listener for this resource if connected
126
- if (eventSource && resource !== '*') {
131
+ if (eventSource && resource !== '*' && isNewResource) {
127
132
  addNamedListener(resource);
128
133
  }
129
134
 
@@ -104,10 +104,7 @@ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOption
104
104
  status = 'disconnected';
105
105
  }
106
106
 
107
- // Auto-connect on creation
108
- if (typeof WebSocket !== 'undefined') {
109
- connect();
110
- }
107
+
111
108
 
112
109
  return {
113
110
  subscribe({ resource, callback }) {
@@ -41,6 +41,7 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
41
41
  TError,
42
42
  UseCreateMutateParams<TVariables>
43
43
  >(() => ({
44
+ ...options.mutationOptions,
44
45
  mutationFn: async (params) => {
45
46
  isMutating = true;
46
47
  const resName = params.resource ?? defaultResource;
@@ -55,16 +56,23 @@ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpEr
55
56
  isMutating = false;
56
57
  }
57
58
  },
58
- onSuccess: (data, params) => {
59
+ onMutate: (options.mutationOptions as any)?.onMutate,
60
+ onSuccess: (data, params, context) => {
59
61
  const resName = params.resource ?? defaultResource;
60
62
  if (params.invalidates !== false) {
61
63
  queryClient.invalidateQueries({ queryKey: [resName] });
62
64
  }
63
65
  fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
64
66
  audit({ action: 'create', resource: resName, recordId: String((data.data as Record<string, unknown>).id) });
67
+ if (typeof options.mutationOptions?.onSuccess === 'function') {
68
+ (options.mutationOptions.onSuccess as Function)(data, params, context);
69
+ }
65
70
  },
66
- onError: (error, params) => {
71
+ onError: (error, params, context) => {
67
72
  fireErrorNotification(params.errorNotification, 'Create failed', error);
73
+ if (typeof options.mutationOptions?.onError === 'function') {
74
+ (options.mutationOptions.onError as Function)(error, params, context);
75
+ }
68
76
  },
69
77
  }));
70
78
 
@@ -115,6 +123,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
115
123
  TError,
116
124
  UseUpdateMutateParams<TVariables>
117
125
  >(() => ({
126
+ ...options.mutationOptions,
118
127
  mutationFn: async (params) => {
119
128
  isMutating = true;
120
129
  const resName = params.resource ?? defaultResource;
@@ -170,7 +179,7 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
170
179
 
171
180
  return { previousDetail, previousList };
172
181
  },
173
- onSuccess: (data, params) => {
182
+ onSuccess: (data, params, context) => {
174
183
  const resName = params.resource ?? defaultResource;
175
184
  const targetId = params.id ?? defaultId;
176
185
  if (params.invalidates !== false) {
@@ -178,6 +187,9 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
178
187
  }
179
188
  fireSuccessNotification(params.successNotification, 'Updated successfully', data.data, params.variables, resName);
180
189
  audit({ action: 'update', resource: resName, recordId: String(targetId) });
190
+ if (typeof options.mutationOptions?.onSuccess === 'function') {
191
+ (options.mutationOptions.onSuccess as Function)(data, params, context);
192
+ }
181
193
  },
182
194
  onError: (error, params, context: unknown) => {
183
195
  const resName = params.resource ?? defaultResource;
@@ -189,6 +201,9 @@ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpEr
189
201
  }
190
202
  if (error instanceof UndoError) return;
191
203
  fireErrorNotification(params.errorNotification, 'Update failed', error);
204
+ if (typeof options.mutationOptions?.onError === 'function') {
205
+ (options.mutationOptions.onError as Function)(error, params, context);
206
+ }
192
207
  },
193
208
  }));
194
209
 
@@ -235,6 +250,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
235
250
  TError,
236
251
  UseDeleteMutateParams<TVariables>
237
252
  >(() => ({
253
+ ...options.mutationOptions,
238
254
  mutationFn: async (params) => {
239
255
  isMutating = true;
240
256
  const resName = params.resource ?? defaultResource;
@@ -278,7 +294,7 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
278
294
 
279
295
  return { previousList };
280
296
  },
281
- onSuccess: (data, params) => {
297
+ onSuccess: (data, params, context) => {
282
298
  const resName = params.resource ?? defaultResource;
283
299
  const targetId = params.id ?? defaultId;
284
300
  if (params.invalidates !== false) {
@@ -286,6 +302,9 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
286
302
  }
287
303
  fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
288
304
  audit({ action: 'delete', resource: resName, recordId: String(targetId) });
305
+ if (typeof options.mutationOptions?.onSuccess === 'function') {
306
+ (options.mutationOptions.onSuccess as Function)(data, params, context);
307
+ }
289
308
  },
290
309
  onError: (error, params, context: unknown) => {
291
310
  const ctx = context as { previousList?: [unknown, unknown][] } | undefined;
@@ -294,6 +313,9 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
294
313
  }
295
314
  if (error instanceof UndoError) return;
296
315
  fireErrorNotification(params.errorNotification, 'Delete failed', error);
316
+ if (typeof options.mutationOptions?.onError === 'function') {
317
+ (options.mutationOptions.onError as Function)(error, params, context);
318
+ }
297
319
  },
298
320
  }));
299
321
 
@@ -16,6 +16,26 @@ import type {
16
16
  } from './types';
17
17
  import type { LiveMode, LiveEvent } from './live.svelte';
18
18
 
19
+ /**
20
+ * Extend a Tanstack Query result with additional properties.
21
+ * The query object is returned as-is, with extra properties accessible via Proxy.
22
+ */
23
+ function extendQuery<Q extends object, E extends Record<string, unknown>>(
24
+ query: Q,
25
+ extensions: () => E,
26
+ ): Q & E {
27
+ return new Proxy(query, {
28
+ get(target, prop, receiver) {
29
+ const ext = extensions();
30
+ if (prop in ext) return ext[prop as keyof E];
31
+ return Reflect.get(target, prop, receiver);
32
+ },
33
+ has(target, prop) {
34
+ return prop in extensions() || Reflect.has(target, prop);
35
+ },
36
+ }) as Q & E;
37
+ }
38
+
19
39
  // ─── useList ───────────────────────────────────────────────────
20
40
 
21
41
  export interface UseListOptions<TData extends BaseRecord = BaseRecord, TError = HttpError> {
@@ -82,10 +102,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
82
102
  }
83
103
  });
84
104
 
85
- return {
86
- query,
87
- get overtime() { return overtime; }
88
- };
105
+ return extendQuery(query, () => ({ overtime }));
89
106
  }
90
107
 
91
108
  // ─── useOne ────────────────────────────────────────────────────
@@ -131,6 +148,18 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
131
148
 
132
149
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
133
150
 
151
+ createLiveSubscription({
152
+ resource,
153
+ liveProvider: getLiveProvider(),
154
+ liveMode: options.liveMode ?? adminOptions.liveMode,
155
+ onLiveEvent: (e) => {
156
+ options.onLiveEvent?.(e);
157
+ adminOptions.onLiveEvent?.(e);
158
+ },
159
+ liveParams: options.liveParams,
160
+ enabled: (queryOptions?.enabled ?? true) && id != null,
161
+ });
162
+
134
163
  $effect(() => {
135
164
  if (query.isSuccess && options.successNotification) {
136
165
  fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
@@ -139,8 +168,9 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
139
168
  }
140
169
  });
141
170
 
142
- return { query, get overtime() { return overtime; } };
171
+ return extendQuery(query, () => ({ overtime }));
143
172
  }
173
+
144
174
  // ─── useShow ──────────────────────────────────────────────────
145
175
  export function useShow<TData extends BaseRecord = BaseRecord, TError = HttpError>(
146
176
  options: UseOneOptions<TData, TError> = {}
@@ -155,12 +185,7 @@ export function useShow<TData extends BaseRecord = BaseRecord, TError = HttpErro
155
185
  get id() { return showId; },
156
186
  });
157
187
 
158
- return {
159
- get query() { return result.query; },
160
- get showId() { return showId; },
161
- setShowId,
162
- get overtime() { return result.overtime; },
163
- };
188
+ return extendQuery(result, () => ({ showId, setShowId }));
164
189
  }
165
190
 
166
191
  // ─── useMany ───────────────────────────────────────────────────
@@ -200,12 +225,24 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
200
225
 
201
226
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
202
227
 
228
+ createLiveSubscription({
229
+ resource,
230
+ liveProvider: getLiveProvider(),
231
+ liveMode: options.liveMode ?? adminOptions.liveMode,
232
+ onLiveEvent: (e) => {
233
+ options.onLiveEvent?.(e);
234
+ adminOptions.onLiveEvent?.(e);
235
+ },
236
+ liveParams: options.liveParams,
237
+ enabled: (queryOptions?.enabled ?? true) && ids.length > 0,
238
+ });
239
+
203
240
  $effect(() => {
204
241
  if (query.isSuccess && options.successNotification) fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
205
242
  else if (query.isError) fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
206
243
  });
207
244
 
208
- return { query, get overtime() { return overtime; } };
245
+ return extendQuery(query, () => ({ overtime }));
209
246
  }
210
247
 
211
248
  export function useApiUrl(dataProviderName?: string): string {
@@ -25,15 +25,17 @@ export interface RouterProvider {
25
25
  export function createHashRouterProvider(): RouterProvider {
26
26
  return {
27
27
  go({ to, query, type = 'push' }) {
28
- let url = `#${to}`;
28
+ let url = to;
29
29
  if (query) {
30
30
  const params = new URLSearchParams(query).toString();
31
31
  if (params) url += `?${params}`;
32
32
  }
33
33
  if (type === 'replace') {
34
- window.location.replace(url);
34
+ const urlObj = new URL(window.location.href);
35
+ urlObj.hash = url;
36
+ window.history.replaceState(null, '', urlObj.href);
35
37
  } else {
36
- window.location.hash = to;
38
+ window.location.hash = url;
37
39
  }
38
40
  },
39
41
  back() {
package/src/router.ts CHANGED
@@ -56,7 +56,7 @@ export function matchRoute(
56
56
  export function navigate(path: string, options?: { replaceState?: boolean }): void {
57
57
  if (_routerProvider) {
58
58
  _routerProvider.go({ to: path, type: options?.replaceState ? 'replace' : 'push' });
59
- } else {
59
+ } else if (typeof window !== 'undefined') {
60
60
  window.location.hash = '#' + path.replace(/^#/, '');
61
61
  }
62
62
  }
@@ -64,7 +64,13 @@ export function navigate(path: string, options?: { replaceState?: boolean }): vo
64
64
  export function currentPath(): string {
65
65
  if (_routerProvider) {
66
66
  const parsed = _routerProvider.parse();
67
- return parsed.pathname || '/';
67
+ const pathname = parsed.pathname || '/';
68
+ let qs = '';
69
+ if (parsed.params && Object.keys(parsed.params).length > 0) {
70
+ qs = '?' + new URLSearchParams(parsed.params).toString();
71
+ }
72
+ return pathname + qs;
68
73
  }
74
+ if (typeof window === 'undefined') return '/';
69
75
  return window.location.hash.replace(/^#/, '') || '/';
70
76
  }
@@ -7,13 +7,14 @@ import { useParsed } from './useParsed.svelte';
7
7
  export function useGetToPath() {
8
8
  const routerProvider = getRouterProvider();
9
9
 
10
- return (options: { resource?: string; action?: 'list' | 'create' | 'edit' | 'show'; id?: string | number; meta?: Record<string, unknown> }) => {
10
+ return (options: { resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number; meta?: Record<string, unknown> }) => {
11
11
  const { resource, action, id } = options;
12
12
  if (!resource) return '/';
13
13
  let path = `/${resource}`;
14
14
  if (action === 'create') path += '/create';
15
15
  else if (action === 'edit' && id) path += `/edit/${id}`;
16
16
  else if (action === 'show' && id) path += `/show/${id}`;
17
+ else if (action === 'clone' && id) path += `/clone/${id}`;
17
18
 
18
19
  // Apply router mapping if the router provider defines custom path transformers
19
20
  if (routerProvider?.go) {
@@ -30,7 +31,7 @@ export function useGo() {
30
31
  return (options: { to?: string; query?: Record<string, unknown>; type?: 'push' | 'replace'; resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number }) => {
31
32
  let targetUrl = options.to;
32
33
  if (!targetUrl && options.resource) {
33
- targetUrl = getToPath({ resource: options.resource, action: options.action === 'clone' ? 'edit' : options.action, id: options.id });
34
+ targetUrl = getToPath({ resource: options.resource, action: options.action, id: options.id });
34
35
  }
35
36
  if (!targetUrl) targetUrl = '/';
36
37
 
@@ -83,7 +83,7 @@ export function useTable<
83
83
  ...restOptions
84
84
  });
85
85
 
86
- const query = tableQueryInfo.query;
86
+ const query = tableQueryInfo;
87
87
  const overtime = tableQueryInfo.overtime;
88
88
 
89
89
  function setSorters(newSorters: Sort[]) { currentSorters = newSorters; }
@@ -115,9 +115,9 @@ export function useTable<
115
115
  writeURLState({
116
116
  page: pagination.current,
117
117
  pageSize: pagination.pageSize,
118
- sortField: effectiveSorters[0]?.field,
119
- sortOrder: effectiveSorters[0]?.order,
120
- filters: effectiveFilters,
118
+ sortField: currentSorters[0]?.field,
119
+ sortOrder: currentSorters[0]?.order,
120
+ filters: currentFilters,
121
121
  });
122
122
  });
123
123
  }
@@ -126,12 +126,12 @@ export function useTable<
126
126
  const params = new URLSearchParams();
127
127
  if (pagination.current) params.set('page', String(pagination.current));
128
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);
129
+ if (currentSorters[0]) {
130
+ params.set('sort', currentSorters[0].field);
131
+ params.set('order', currentSorters[0].order);
132
132
  }
133
- if (effectiveFilters.length > 0) {
134
- params.set('filters', JSON.stringify(effectiveFilters));
133
+ if (currentFilters.length > 0) {
134
+ params.set('filters', JSON.stringify(currentFilters));
135
135
  }
136
136
  return `?${params.toString()}`;
137
137
  }
@@ -111,9 +111,9 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
111
111
  writeURLState({
112
112
  page: this.pagination.current,
113
113
  pageSize: this.pagination.pageSize,
114
- sortField: this.effectiveSorters[0]?.field,
115
- sortOrder: this.effectiveSorters[0]?.order,
116
- filters: this.effectiveFilters,
114
+ sortField: this.currentSorters[0]?.field,
115
+ sortOrder: this.currentSorters[0]?.order,
116
+ filters: this.currentFilters,
117
117
  });
118
118
  });
119
119
  }
@@ -130,7 +130,7 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
130
130
  }
131
131
 
132
132
  /** Underlying TanStack Query result */
133
- get query() { return this._queryResult.query; }
133
+ get query() { return this._queryResult; }
134
134
 
135
135
  /** Table data rows */
136
136
  get data(): TData[] { return (this.query.data as GetListResult<TData> | undefined)?.data ?? []; }
@@ -301,8 +301,8 @@ export const colorThemes: { id: ColorTheme | string; label: string; color: strin
301
301
  // ── Dark/Light mode ──────────────────────────────────────
302
302
 
303
303
  function getStoredTheme(): ThemeMode {
304
- if (typeof localStorage === 'undefined') return 'system';
305
- return (localStorage.getItem(STORAGE_KEY) as ThemeMode) ?? 'system';
304
+ if (typeof window === 'undefined') return 'system';
305
+ try { return (localStorage.getItem(STORAGE_KEY) as ThemeMode) ?? 'system'; } catch { return 'system'; }
306
306
  }
307
307
 
308
308
  function getSystemPreference(): 'light' | 'dark' {
@@ -356,8 +356,8 @@ export function getTheme(): ThemeMode {
356
356
 
357
357
  export function setTheme(newMode: ThemeMode): void {
358
358
  mode = newMode;
359
- if (typeof localStorage !== 'undefined') {
360
- localStorage.setItem(STORAGE_KEY, newMode);
359
+ if (typeof window !== 'undefined') {
360
+ try { localStorage.setItem(STORAGE_KEY, newMode); } catch {}
361
361
  }
362
362
  applyTheme(newMode);
363
363
  // Re-apply color preset — light/dark CSS variable overrides differ
@@ -378,8 +378,8 @@ export function getResolvedTheme(): 'light' | 'dark' {
378
378
  // ── Color theme ──────────────────────────────────────────
379
379
 
380
380
  function getStoredColorTheme(): ColorTheme {
381
- if (typeof localStorage === 'undefined') return 'blue';
382
- return (localStorage.getItem(COLOR_STORAGE_KEY) as ColorTheme) ?? 'blue';
381
+ if (typeof window === 'undefined') return 'blue';
382
+ try { return (localStorage.getItem(COLOR_STORAGE_KEY) as ColorTheme) ?? 'blue'; } catch { return 'blue'; }
383
383
  }
384
384
 
385
385
  let colorTheme = $state<ColorTheme>(getStoredColorTheme());
@@ -403,8 +403,8 @@ export function getColorTheme(): ColorTheme {
403
403
 
404
404
  export function setColorTheme(ct: ColorTheme): void {
405
405
  colorTheme = ct;
406
- if (typeof localStorage !== 'undefined') {
407
- localStorage.setItem(COLOR_STORAGE_KEY, ct);
406
+ if (typeof window !== 'undefined') {
407
+ try { localStorage.setItem(COLOR_STORAGE_KEY, ct); } catch {}
408
408
  }
409
409
  applyColorTheme(ct);
410
410
  // Apply the preset's CSS variable overrides for the current resolved theme
@@ -98,7 +98,6 @@ export function initUnsavedChangesNotifier(options?: {
98
98
  const onHashChange = (e: HashChangeEvent) => {
99
99
  if (!dirty) return;
100
100
  if (!confirm(t('common.unsavedChanges'))) {
101
- e.preventDefault();
102
101
  if (e.oldURL) {
103
102
  history.replaceState(null, '', new URL(e.oldURL).hash || '#');
104
103
  }
@@ -5,7 +5,7 @@ import { getResources } from './context.svelte';
5
5
 
6
6
  interface ParsedRoute {
7
7
  resource?: string;
8
- action?: 'list' | 'create' | 'edit' | 'show';
8
+ action?: 'list' | 'create' | 'edit' | 'show' | 'clone';
9
9
  id?: string;
10
10
  params: Record<string, string>;
11
11
  }
@@ -73,6 +73,9 @@ export function useParsed(): ParsedRoute {
73
73
  } else if (restSegments[0] === 'show' && restSegments[1]) {
74
74
  result.action = 'show';
75
75
  result.id = restSegments[1];
76
+ } else if (restSegments[0] === 'clone' && restSegments[1]) {
77
+ result.action = 'clone';
78
+ result.id = restSegments[1];
76
79
  } else if (restSegments[0]) {
77
80
  // Legacy: /:resource/:id
78
81
  result.action = 'show';
@@ -92,6 +95,9 @@ export function useParsed(): ParsedRoute {
92
95
  } else if (segments[1] === 'show' && segments[2]) {
93
96
  result.action = 'show';
94
97
  result.id = segments[2];
98
+ } else if (segments[1] === 'clone' && segments[2]) {
99
+ result.action = 'clone';
100
+ result.id = segments[2];
95
101
  } else if (segments[1]) {
96
102
  result.action = 'show';
97
103
  result.id = segments[1];