@svadmin/core 0.22.1 → 0.23.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.
@@ -3,6 +3,7 @@ import { getAdminOptions } from './options.svelte';
3
3
  import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
4
4
  import { useParsed } from './useParsed.svelte';
5
5
  import {
6
+ checkError,
6
7
  createOvertimeTracker,
7
8
  createLiveSubscription,
8
9
  fireSuccessNotification,
@@ -22,19 +23,18 @@ import type { LiveMode, LiveEvent } from './live.svelte';
22
23
  */
23
24
  function extendQuery<Q extends object, E extends Record<string, unknown>>(
24
25
  query: Q,
25
- extensions: () => E,
26
+ extensions: E,
26
27
  ): Q & E {
27
28
  return new Proxy(query, {
28
29
  get(target, prop) {
29
- if (typeof prop === 'string') {
30
- const ext = extensions();
31
- if (prop in ext) return ext[prop as keyof E];
30
+ if (typeof prop === 'string' && prop in extensions) {
31
+ return extensions[prop as keyof E];
32
32
  }
33
- return target[prop as keyof Q];
33
+ return Reflect.get(target, prop);
34
34
  },
35
35
  has(target, prop) {
36
- if (typeof prop === 'string' && prop in extensions()) return true;
37
- return prop in target;
36
+ if (typeof prop === 'string' && prop in extensions) return true;
37
+ return Reflect.has(target, prop);
38
38
  },
39
39
  }) as Q & E;
40
40
  }
@@ -73,7 +73,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
73
73
  const queryOptions = opts.queryOptions;
74
74
 
75
75
  return {
76
- queryKey: [resource, 'list', opts.pagination, opts.sorters, opts.filters, opts.meta],
76
+ queryKey: [opts.dataProviderName, resource, 'list', opts.pagination, opts.sorters, opts.filters, opts.meta],
77
77
  queryFn: async () => provider.getList<TData>({
78
78
  resource,
79
79
  pagination: opts.pagination,
@@ -105,16 +105,22 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
105
105
  };
106
106
  });
107
107
 
108
+ // Track last notification timestamps to prevent duplicate firings on re-render
109
+ let lastSuccessAt = 0;
110
+ let lastErrorAt = 0;
108
111
  $effect(() => {
109
112
  const opts = getOptions();
110
- if (query.isSuccess && opts.successNotification) {
113
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
114
+ lastSuccessAt = query.dataUpdatedAt;
111
115
  fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
112
- } else if (query.isError) {
116
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
117
+ lastErrorAt = query.errorUpdatedAt;
118
+ checkError(query.error);
113
119
  fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
114
120
  }
115
121
  });
116
122
 
117
- return extendQuery(query, () => ({ overtime }));
123
+ return extendQuery(query, { overtime });
118
124
  }
119
125
 
120
126
  // ─── useOne ────────────────────────────────────────────────────
@@ -149,7 +155,7 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
149
155
  const queryOptions = opts.queryOptions;
150
156
 
151
157
  return {
152
- queryKey: [resource, 'one', id, opts.meta],
158
+ queryKey: [opts.dataProviderName, resource, 'one', id, opts.meta],
153
159
  queryFn: async () => {
154
160
  if (id == null) throw new Error('useOne requires an id');
155
161
  const result = await provider.getOne<TData>({
@@ -168,16 +174,37 @@ export function useOne<TData extends BaseRecord = BaseRecord, TError = HttpError
168
174
 
169
175
  const overtime = createOvertimeTracker(() => query.isLoading, typeof optionsOrGetter === 'function' ? optionsOrGetter().overtimeOptions : optionsOrGetter.overtimeOptions ?? adminOptions.overtime);
170
176
 
177
+ createLiveSubscription((): LiveSubscriptionParams => {
178
+ const opts = getOptions();
179
+ const resource = getResource();
180
+ return {
181
+ resource,
182
+ liveProvider: getLiveProvider(),
183
+ liveMode: opts.liveMode ?? adminOptions.liveMode,
184
+ onLiveEvent: (e: LiveEvent) => {
185
+ opts.onLiveEvent?.(e);
186
+ adminOptions.onLiveEvent?.(e);
187
+ },
188
+ liveParams: opts.liveParams,
189
+ enabled: (opts.queryOptions?.enabled ?? true) && getId() != null,
190
+ };
191
+ });
192
+
193
+ let lastSuccessAt = 0;
194
+ let lastErrorAt = 0;
171
195
  $effect(() => {
172
196
  const opts = getOptions();
173
- if (query.isSuccess && opts.successNotification) {
197
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
198
+ lastSuccessAt = query.dataUpdatedAt;
174
199
  fireSuccessNotification(opts.successNotification, '', query.data, undefined, getResource());
175
- } else if (query.isError) {
200
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
201
+ lastErrorAt = query.errorUpdatedAt;
202
+ checkError(query.error);
176
203
  fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
177
204
  }
178
205
  });
179
206
 
180
- return extendQuery(query, () => ({ overtime }));
207
+ return extendQuery(query, { overtime });
181
208
  }
182
209
 
183
210
  // ─── useShow ──────────────────────────────────────────────────
@@ -200,7 +227,7 @@ export function useShow<TData extends BaseRecord = BaseRecord, TError = HttpErro
200
227
  id: showId,
201
228
  }));
202
229
 
203
- return extendQuery(result, () => ({ showId, setShowId }));
230
+ return extendQuery(result, { get showId() { return showId; }, setShowId });
204
231
  }
205
232
 
206
233
  // ─── useMany ───────────────────────────────────────────────────
@@ -229,7 +256,7 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
229
256
  const provider = getDataProviderForResource(resource, dataProviderName);
230
257
 
231
258
  return {
232
- queryKey: [resource, 'many', ids, meta],
259
+ queryKey: [dataProviderName, resource, 'many', ids, meta],
233
260
  queryFn: async () => {
234
261
  if (!ids.length) return { data: [] };
235
262
  if (provider.getMany) {
@@ -247,13 +274,36 @@ export function useMany<TData extends BaseRecord = BaseRecord, TError = HttpErro
247
274
 
248
275
  const overtime = createOvertimeTracker(() => query.isLoading, typeof optionsOrGetter === 'function' ? optionsOrGetter().overtimeOptions : optionsOrGetter.overtimeOptions ?? adminOptions.overtime);
249
276
 
277
+ createLiveSubscription((): LiveSubscriptionParams => {
278
+ const opts = getOptions();
279
+ return {
280
+ resource: opts.resource,
281
+ liveProvider: getLiveProvider(),
282
+ liveMode: opts.liveMode ?? adminOptions.liveMode,
283
+ onLiveEvent: (e: LiveEvent) => {
284
+ opts.onLiveEvent?.(e);
285
+ adminOptions.onLiveEvent?.(e);
286
+ },
287
+ liveParams: opts.liveParams,
288
+ enabled: (opts.queryOptions?.enabled ?? true) && opts.ids.length > 0,
289
+ };
290
+ });
291
+
292
+ let lastSuccessAt = 0;
293
+ let lastErrorAt = 0;
250
294
  $effect(() => {
251
295
  const opts = getOptions();
252
- if (query.isSuccess && opts.successNotification) fireSuccessNotification(opts.successNotification, '', query.data, undefined, opts.resource);
253
- else if (query.isError) fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
296
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && opts.successNotification) {
297
+ lastSuccessAt = query.dataUpdatedAt;
298
+ fireSuccessNotification(opts.successNotification, '', query.data, undefined, opts.resource);
299
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
300
+ lastErrorAt = query.errorUpdatedAt;
301
+ checkError(query.error);
302
+ fireErrorNotification(opts.errorNotification, 'Fetch failed', query.error);
303
+ }
254
304
  });
255
305
 
256
- return extendQuery(query, () => ({ overtime }));
306
+ return extendQuery(query, { overtime });
257
307
  }
258
308
 
259
309
  export function useApiUrl(dataProviderName?: string): string {
@@ -100,7 +100,9 @@ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
100
100
  if (typeof window === 'undefined') {
101
101
  return { resource: undefined, action: undefined, id: undefined, params: {}, pathname: '/' };
102
102
  }
103
- const pathname = window.location.pathname.replace(basePath, '') || '/';
103
+ const pathname = (basePath && window.location.pathname.startsWith(basePath)
104
+ ? window.location.pathname.slice(basePath.length) || '/'
105
+ : window.location.pathname);
104
106
  const params: Record<string, string> = {};
105
107
  for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
106
108
  params[k] = v;
package/src/router.ts CHANGED
@@ -7,13 +7,26 @@ interface RouteMatch {
7
7
  params: Record<string, string>;
8
8
  }
9
9
 
10
- // Store a reference to the active RouterProvider (set by router-state.svelte.ts)
10
+ export type RouteGuard = (to: string, from: string) => boolean | Promise<boolean>;
11
+
11
12
  let _routerProvider: RouterProvider | undefined;
13
+ let _beforeGuards: RouteGuard[] = [];
14
+ let _afterGuards: ((to: string, from: string) => void)[] = [];
12
15
 
13
16
  export function setActiveRouterProvider(provider: RouterProvider | undefined) {
14
17
  _routerProvider = provider;
15
18
  }
16
19
 
20
+ export function beforeEach(guard: RouteGuard): () => void {
21
+ _beforeGuards.push(guard);
22
+ return () => { _beforeGuards = _beforeGuards.filter(g => g !== guard); };
23
+ }
24
+
25
+ export function afterEach(guard: (to: string, from: string) => void): () => void {
26
+ _afterGuards.push(guard);
27
+ return () => { _afterGuards = _afterGuards.filter(g => g !== guard); };
28
+ }
29
+
17
30
  // Parse route pattern like /products/edit/:id into regex
18
31
  function compileRoute(pattern: string): { regex: RegExp; keys: string[] } {
19
32
  const keys: string[] = [];
@@ -50,12 +63,20 @@ export function matchRoute(
50
63
  return null;
51
64
  }
52
65
 
53
- export function navigate(path: string, options?: { replaceState?: boolean }): void {
66
+ export async function navigate(path: string, options?: { replaceState?: boolean }): Promise<void> {
67
+ const from = currentPath();
68
+ for (const guard of _beforeGuards) {
69
+ const allowed = await guard(path, from);
70
+ if (!allowed) return;
71
+ }
54
72
  if (_routerProvider) {
55
73
  _routerProvider.go({ to: path, type: options?.replaceState ? 'replace' : 'push' });
56
74
  } else if (typeof window !== 'undefined') {
57
75
  window.location.hash = '#' + path.replace(/^#/, '');
58
76
  }
77
+ for (const guard of _afterGuards) {
78
+ guard(path, from);
79
+ }
59
80
  }
60
81
 
61
82
  /**
@@ -6,11 +6,24 @@ import { useParsed } from './useParsed.svelte';
6
6
 
7
7
  export function useGetToPath() {
8
8
  const routerProvider = getRouterProvider();
9
+ const parsed = useParsed();
9
10
 
10
11
  return (options: { resource?: string; action?: 'list' | 'create' | 'edit' | 'show' | 'clone'; id?: string | number; meta?: Record<string, unknown> }) => {
11
- const { resource, action, id } = options;
12
+ const { resource, action, id, meta } = options;
12
13
  if (!resource) return '/';
13
- let path = `/${resource}`;
14
+
15
+ // Support nested resources: use meta.parentPath or auto-detect from current URL
16
+ let basePath: string;
17
+ if (meta?.parentPath) {
18
+ basePath = `${meta.parentPath}/${resource}`;
19
+ } else if (parsed.resourcePath && parsed.resource === resource) {
20
+ // Re-use the current nested path context (e.g., /teams/123/users)
21
+ basePath = `/${parsed.resourcePath}`;
22
+ } else {
23
+ basePath = `/${resource}`;
24
+ }
25
+
26
+ let path = basePath;
14
27
  if (action === 'create') path += '/create';
15
28
  else if (action === 'edit' && id) path += `/edit/${id}`;
16
29
  else if (action === 'show' && id) path += `/show/${id}`;
@@ -70,16 +83,28 @@ export function useLink() {
70
83
 
71
84
  export function useResource(resourceName?: string) {
72
85
  const parsed = useParsed();
73
- const targetResource = resourceName ?? parsed.resource;
74
- if (!targetResource) {
75
- return { resource: undefined, resources: getResources() };
76
- }
77
- try {
78
- const resource = getResource(targetResource);
79
- return { resource, resources: getResources() };
80
- } catch {
81
- return { resource: undefined, resources: getResources() };
82
- }
86
+
87
+ // Derived resource — re-evaluates when parsed.resource or resourceName changes
88
+ const resolvedResource = $derived.by(() => {
89
+ const target = resourceName ?? parsed.resource;
90
+ if (!target) return undefined;
91
+ try {
92
+ return getResource(target);
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ });
97
+
98
+ return {
99
+ get resource() { return resolvedResource; },
100
+ get resources() { return getResources(); },
101
+ get identifier() { return resolvedResource?.identifier ?? resolvedResource?.name; },
102
+ /** Resolve a resource by name — useful for dynamic lookups inside callbacks */
103
+ select: (name: string) => {
104
+ const res = getResource(name);
105
+ return { resource: res, identifier: res.identifier ?? res.name };
106
+ },
107
+ };
83
108
  }
84
109
 
85
110
  export function useNavigation() {
@@ -1,6 +1,7 @@
1
1
  import { useParsed } from './useParsed.svelte';
2
2
  import { useList } from './query-hooks.svelte';
3
3
  import { readURLState, writeURLState } from './url-sync';
4
+ import { getAdminOptions } from './options.svelte';
4
5
  import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
5
6
  import type { UseListOptions, MaybeGetter } from './query-hooks.svelte';
6
7
 
@@ -34,6 +35,7 @@ export function useTable<
34
35
  // Use a local derived to compute these since they are used to initialize state
35
36
  // Even if options change, init values are only used once, which makes sense for initial state
36
37
  const initialOpts = getOptions();
38
+ const syncWithLocation = initialOpts.syncWithLocation ?? getAdminOptions().syncWithLocation;
37
39
 
38
40
  const paginationMode = $derived(getOptions().pagination?.mode ?? 'server');
39
41
  const sortersMode = $derived(getOptions().sorters?.mode ?? 'server');
@@ -47,7 +49,7 @@ export function useTable<
47
49
  let initSorters = initialOpts.sorters?.initial ?? [];
48
50
  let initFilters = initialOpts.filters?.initial ?? [];
49
51
 
50
- if (initialOpts.syncWithLocation && typeof window !== 'undefined') {
52
+ if (syncWithLocation && typeof window !== 'undefined') {
51
53
  const urlState = readURLState();
52
54
  if (urlState.page || urlState.pageSize) {
53
55
  initPagination = {
@@ -93,13 +95,14 @@ export function useTable<
93
95
  const query = tableQueryInfo;
94
96
  const overtime = tableQueryInfo.overtime;
95
97
 
96
- function setSorters(newSorters: Sort[]) { currentSorters = newSorters; }
98
+ function setSorters(newSorters: Sort[]) { currentSorters = newSorters; pagination = { ...pagination, current: 1 }; }
97
99
  function setFilters(newFilters: Filter[], mode?: FilterSetMode) {
98
100
  const behavior = mode ?? filterDefaultBehavior;
99
101
  if (behavior === 'merge') {
100
102
  const merged = [...currentFilters];
101
103
  for (const nf of newFilters) {
102
- const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
104
+ if (!('field' in nf)) { merged.push(nf); continue; }
105
+ const idx = merged.findIndex(f => 'field' in f && f.field === nf.field && f.operator === nf.operator);
103
106
  if (idx >= 0) {
104
107
  if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
105
108
  else merged[idx] = nf;
@@ -117,7 +120,7 @@ export function useTable<
117
120
  function setPage(page: number) { pagination = { ...pagination, current: page }; }
118
121
  function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
119
122
 
120
- if (initialOpts.syncWithLocation && typeof window !== 'undefined') {
123
+ if (syncWithLocation && typeof window !== 'undefined') {
121
124
  $effect(() => {
122
125
  writeURLState({
123
126
  page: pagination.current,
@@ -127,6 +130,39 @@ export function useTable<
127
130
  filters: currentFilters,
128
131
  });
129
132
  });
133
+
134
+ $effect(() => {
135
+ const handler = () => {
136
+ const urlState = readURLState();
137
+ const newPage = urlState.page ?? 1;
138
+ if (newPage !== pagination.current) {
139
+ pagination = { ...pagination, current: newPage };
140
+ }
141
+ const newPageSize = urlState.pageSize ?? initialOpts.pagination?.pageSize ?? getAdminOptions().defaultPageSize ?? 10;
142
+ if (newPageSize !== pagination.pageSize) {
143
+ pagination = { ...pagination, pageSize: newPageSize };
144
+ }
145
+ if (urlState.sortField) {
146
+ const newSorter: Sort = { field: urlState.sortField, order: (urlState.sortOrder as 'asc' | 'desc') ?? 'asc' };
147
+ if (JSON.stringify([newSorter]) !== JSON.stringify(currentSorters)) {
148
+ currentSorters = [newSorter];
149
+ }
150
+ } else if (currentSorters.length > 0) {
151
+ currentSorters = [];
152
+ }
153
+ if (urlState.filters && JSON.stringify(urlState.filters) !== JSON.stringify(currentFilters)) {
154
+ currentFilters = urlState.filters;
155
+ } else if (!urlState.filters && currentFilters.length > 0) {
156
+ currentFilters = [];
157
+ }
158
+ };
159
+ window.addEventListener('popstate', handler);
160
+ window.addEventListener('hashchange', handler);
161
+ return () => {
162
+ window.removeEventListener('popstate', handler);
163
+ window.removeEventListener('hashchange', handler);
164
+ };
165
+ });
130
166
  }
131
167
 
132
168
  function createLinkForSyncWithLocation(): string {
@@ -160,23 +196,33 @@ export function useTable<
160
196
  get pageSize() { return pagination.pageSize ?? 10; },
161
197
  get pageCount() { return Math.ceil(((query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0) / (pagination.pageSize ?? 10)); },
162
198
  get clientData(): TQueryFnData[] {
163
- if (paginationMode !== 'client') return [];
199
+ if (paginationMode === 'server') return [];
164
200
  const allData = ((query.data as GetListResult<TQueryFnData> | undefined)?.data ?? []) as TQueryFnData[];
165
- const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
166
- const end = start + (pagination.pageSize ?? 10);
167
201
  let sorted = [...allData];
168
- const activeSorters = sortersMode === 'off' ? effectiveSorters : currentSorters;
202
+ const activeSorters = sortersMode === 'off' ? effectiveSorters : [];
169
203
  if (activeSorters.length > 0) {
170
- const { field, order } = activeSorters[0];
171
204
  sorted.sort((a, b) => {
172
- const va = (a as Record<string, unknown>)[field];
173
- const vb = (b as Record<string, unknown>)[field];
174
- if (va == null) return 1;
175
- if (vb == null) return -1;
176
- const cmp = va < vb ? -1 : va > vb ? 1 : 0;
177
- return order === 'desc' ? -cmp : cmp;
205
+ for (const { field, order } of activeSorters) {
206
+ const va = (a as Record<string, unknown>)[field];
207
+ const vb = (b as Record<string, unknown>)[field];
208
+ if (va == null && vb != null) return 1;
209
+ if (vb == null && va != null) return -1;
210
+ if (va == null && vb == null) continue;
211
+
212
+ let cmp = 0;
213
+ if (typeof va === 'number' && typeof vb === 'number') cmp = va - vb;
214
+ else if (va instanceof Date && vb instanceof Date) cmp = va.getTime() - vb.getTime();
215
+ else if (typeof va === 'string' && typeof vb === 'string' && !isNaN(Number(va)) && !isNaN(Number(vb))) cmp = Number(va) - Number(vb);
216
+ else cmp = String(va).localeCompare(String(vb));
217
+
218
+ if (cmp !== 0) return order === 'desc' ? -cmp : cmp;
219
+ }
220
+ return 0;
178
221
  });
179
222
  }
223
+ if (paginationMode === 'off') return sorted;
224
+ const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
225
+ const end = start + (pagination.pageSize ?? 10);
180
226
  return sorted.slice(start, end);
181
227
  },
182
228
  };
@@ -200,7 +200,8 @@ export class TableState<TData extends BaseRecord = BaseRecord, TError = HttpErro
200
200
  if (behavior === 'merge') {
201
201
  const merged = [...this.currentFilters];
202
202
  for (const nf of newFilters) {
203
- const idx = merged.findIndex(f => f.field === nf.field && f.operator === nf.operator);
203
+ if (!('field' in nf)) { merged.push(nf); continue; }
204
+ const idx = merged.findIndex(f => 'field' in f && f.field === nf.field && f.operator === nf.operator);
204
205
  if (idx >= 0) {
205
206
  if (nf.value === undefined || nf.value === null || nf.value === '') merged.splice(idx, 1);
206
207
  else merged[idx] = nf;
@@ -134,13 +134,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
134
134
  '--chart-1': 'oklch(0.577 0.245 27.325)',
135
135
  },
136
136
  dark: {
137
- '--primary': 'oklch(0.577 0.245 27.325)',
138
- '--primary-foreground': 'oklch(0.985 0 0)',
139
- '--ring': 'oklch(0.577 0.245 27.325)',
140
- '--sidebar-primary': 'oklch(0.577 0.245 27.325)',
137
+ '--primary': 'oklch(0.685 0.22 27)',
138
+ '--primary-foreground': 'oklch(0.15 0.04 27)',
139
+ '--ring': 'oklch(0.55 0.18 27)',
140
+ '--sidebar-primary': 'oklch(0.685 0.22 27)',
141
141
  '--sidebar-primary-foreground': 'oklch(0.985 0 0)',
142
- '--sidebar-ring': 'oklch(0.577 0.245 27.325)',
143
- '--chart-1': 'oklch(0.577 0.245 27.325)',
142
+ '--sidebar-ring': 'oklch(0.55 0.18 27)',
143
+ '--chart-1': 'oklch(0.685 0.22 27)',
144
144
  },
145
145
  },
146
146
  orange: {
@@ -157,13 +157,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
157
157
  '--chart-1': 'oklch(0.646 0.222 41.116)',
158
158
  },
159
159
  dark: {
160
- '--primary': 'oklch(0.646 0.222 41.116)',
161
- '--primary-foreground': 'oklch(0.985 0 0)',
162
- '--ring': 'oklch(0.646 0.222 41.116)',
163
- '--sidebar-primary': 'oklch(0.646 0.222 41.116)',
160
+ '--primary': 'oklch(0.73 0.195 41)',
161
+ '--primary-foreground': 'oklch(0.15 0.04 41)',
162
+ '--ring': 'oklch(0.6 0.18 41)',
163
+ '--sidebar-primary': 'oklch(0.73 0.195 41)',
164
164
  '--sidebar-primary-foreground': 'oklch(0.985 0 0)',
165
- '--sidebar-ring': 'oklch(0.646 0.222 41.116)',
166
- '--chart-1': 'oklch(0.646 0.222 41.116)',
165
+ '--sidebar-ring': 'oklch(0.6 0.18 41)',
166
+ '--chart-1': 'oklch(0.73 0.195 41)',
167
167
  },
168
168
  },
169
169
  violet: {
@@ -180,13 +180,13 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
180
180
  '--chart-1': 'oklch(0.541 0.281 293.009)',
181
181
  },
182
182
  dark: {
183
- '--primary': 'oklch(0.541 0.281 293.009)',
184
- '--primary-foreground': 'oklch(0.985 0 0)',
185
- '--ring': 'oklch(0.541 0.281 293.009)',
186
- '--sidebar-primary': 'oklch(0.541 0.281 293.009)',
183
+ '--primary': 'oklch(0.65 0.25 293)',
184
+ '--primary-foreground': 'oklch(0.15 0.04 293)',
185
+ '--ring': 'oklch(0.52 0.2 293)',
186
+ '--sidebar-primary': 'oklch(0.65 0.25 293)',
187
187
  '--sidebar-primary-foreground': 'oklch(0.985 0 0)',
188
- '--sidebar-ring': 'oklch(0.541 0.281 293.009)',
189
- '--chart-1': 'oklch(0.541 0.281 293.009)',
188
+ '--sidebar-ring': 'oklch(0.52 0.2 293)',
189
+ '--chart-1': 'oklch(0.65 0.25 293)',
190
190
  },
191
191
  },
192
192
  });
@@ -194,11 +194,6 @@ export const builtinPresets: Record<string, ColorPreset> = $state({
194
194
  /** Register a custom color preset. Overwrites any built-in preset with the same name. */
195
195
  export function registerColorPreset(preset: ColorPreset): void {
196
196
  builtinPresets[preset.name] = preset;
197
- // Update colorThemes display list
198
- const idx = colorThemes.findIndex(ct => ct.id === preset.name);
199
- const entry = { id: preset.name as ColorTheme, label: preset.label, color: preset.color };
200
- if (idx >= 0) colorThemes[idx] = entry;
201
- else colorThemes.push(entry);
202
197
  }
203
198
 
204
199
  /** Resolve a preset by name or return the preset object directly. */
@@ -208,14 +203,21 @@ function resolvePreset(preset: ColorPreset | string): ColorPreset | undefined {
208
203
  }
209
204
 
210
205
  /** Apply a color preset's CSS variables based on current resolved theme. */
206
+ let activePresetVars: string[] = [];
207
+
211
208
  function applyColorPreset(preset: ColorPreset): void {
212
209
  if (typeof document === 'undefined') return;
213
210
  const resolved = mode === 'system' ? getSystemPreference() : mode;
214
211
  const vars = resolved === 'dark' ? preset.dark : preset.light;
215
212
  const root = document.documentElement;
213
+ for (const key of activePresetVars) {
214
+ root.style.removeProperty(key);
215
+ }
216
+ activePresetVars = [];
216
217
  for (const [key, value] of Object.entries(vars)) {
217
218
  const cssVar = key.startsWith('--') ? key : `--${key}`;
218
219
  root.style.setProperty(cssVar, value);
220
+ activePresetVars.push(cssVar);
219
221
  }
220
222
  }
221
223
 
@@ -292,7 +294,7 @@ export function clearCssOverrides(keys?: string[]): void {
292
294
  }
293
295
 
294
296
  // ── Color themes (display metadata, derived from presets) ──
295
- export const colorThemes: { id: ColorTheme | string; label: string; color: string }[] = $state(Object.values(builtinPresets).map(p => ({
297
+ export const colorThemes: { id: ColorTheme | string; label: string; color: string }[] = $derived(Object.values(builtinPresets).map(p => ({
296
298
  id: p.name as ColorTheme,
297
299
  label: p.label,
298
300
  color: p.color,
@@ -2,6 +2,7 @@ export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
2
2
 
3
3
  export interface ToastItem {
4
4
  id: number;
5
+ key?: string;
5
6
  type: ToastType;
6
7
  message: string;
7
8
  duration?: number;
@@ -10,18 +11,16 @@ export interface ToastItem {
10
11
  }
11
12
 
12
13
  let nextId = 0;
14
+ const undoableTimers = new Map<number, ReturnType<typeof setTimeout>>();
13
15
 
14
- // Queue for simple toasts (consumed by UI Toast bridge)
15
16
  let queue = $state<ToastItem[]>([]);
16
17
  export function getToastQueue() { return queue; }
17
18
  export function consumeToastQueue() { queue = []; }
18
19
 
19
- // Promise Queue
20
20
  let promiseQueue = $state<{ id: number; promise: Promise<any>; opts: { loading: string; success: string; error: string } }[]>([]);
21
21
  export function getPromiseQueue() { return promiseQueue; }
22
22
  export function consumePromiseQueue() { promiseQueue = []; }
23
23
 
24
- // Legacy compatibility — kept for existing consumers (Undoable)
25
24
  let toasts = $state<ToastItem[]>([]);
26
25
  export function getToasts(): ToastItem[] { return toasts; }
27
26
 
@@ -29,29 +28,41 @@ export function addToast(
29
28
  type: ToastType,
30
29
  message: string,
31
30
  duration = 3000,
32
- options?: { onUndo?: () => void, onTimeout?: () => void }
31
+ options?: { key?: string, onUndo?: () => void, onTimeout?: () => void }
33
32
  ): void {
33
+ if (options?.key) {
34
+ if (queue.some(q => q.key === options.key) || toasts.some(t => t.key === options.key)) return;
35
+ }
34
36
  if (type !== 'undoable') {
35
- queue.push({ id: nextId++, type, message, duration });
37
+ queue.push({ id: nextId++, type, message, duration, key: options?.key });
36
38
  return;
37
39
  }
38
40
  const id = nextId++;
39
41
  toasts = [...toasts, { id, type, message, duration, ...options }];
42
+ const timer = setTimeout(() => {
43
+ undoableTimers.delete(id);
44
+ options?.onTimeout?.();
45
+ removeToast(id);
46
+ }, duration);
47
+ undoableTimers.set(id, timer);
40
48
  }
41
49
 
42
50
  export function removeToast(id: number): void {
51
+ const timer = undoableTimers.get(id);
52
+ if (timer) {
53
+ clearTimeout(timer);
54
+ undoableTimers.delete(id);
55
+ }
43
56
  toasts = toasts.filter(t => t.id !== id);
44
57
  }
45
58
 
46
- // Convenience methods — backward compatible
47
59
  export const toast = {
48
- success: (msg: string, duration?: number) => addToast('success', msg, duration),
49
- error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
50
- info: (msg: string, duration?: number) => addToast('info', msg, duration),
51
- warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
60
+ success: (msg: string, duration?: number, opts?: { key?: string }) => addToast('success', msg, duration, opts),
61
+ error: (msg: string, duration?: number, opts?: { key?: string }) => addToast('error', msg, duration ?? 5000, opts),
62
+ info: (msg: string, duration?: number, opts?: { key?: string }) => addToast('info', msg, duration, opts),
63
+ warning: (msg: string, duration?: number, opts?: { key?: string }) => addToast('warning', msg, duration ?? 4000, opts),
52
64
  undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
53
65
  addToast('undoable', msg, duration, { onUndo, onTimeout }),
54
- /** Promise toast — shows loading → success/error automatically */
55
66
  promise: <T>(promise: Promise<T>, opts: { loading: string; success: string; error: string }) => {
56
67
  promiseQueue.push({ id: nextId++, promise, opts });
57
68
  return promise;
package/src/types.ts CHANGED
@@ -48,12 +48,14 @@ export type CrudOperator =
48
48
  | 'null' | 'nnull'
49
49
  | 'between' | 'nbetween';
50
50
 
51
- export interface Filter {
51
+ export interface FieldFilter {
52
52
  field: string;
53
53
  operator: CrudOperator;
54
54
  value: unknown;
55
55
  }
56
56
 
57
+ export type Filter = FieldFilter | LogicalFilter;
58
+
57
59
  export interface LogicalFilter {
58
60
  operator: 'or' | 'and';
59
61
  value: Filter[];