@svadmin/core 0.3.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -59,5 +59,8 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/bun": "^1.3.11"
62
+ },
63
+ "dependencies": {
64
+ "svelte-sonner": "^1.1.0"
62
65
  }
63
66
  }
@@ -1,9 +1,8 @@
1
1
  // Auth Hooks — reactive wrappers around AuthProvider methods
2
2
  // Each hook encapsulates the auth call + loading state + error handling + redirect
3
- // IMPORTANT: getAuthProvider() must be called at hook creation time (during component init),
4
- // not inside mutate(), because Svelte's getContext() only works during init.
3
+ // Uses module-level $state — no component init-time constraints.
5
4
 
6
- import { getAuthProvider } from './context';
5
+ import { getAuthProvider } from './context.svelte';
7
6
  import { navigate } from './router';
8
7
  import { toast } from './toast.svelte';
9
8
  import { t } from './i18n.svelte';
@@ -1,14 +1,9 @@
1
- // Svelte context — provides DataProvider, AuthProvider, and Resources to all components
1
+ // Svelte 5 Context — module-level $state singletons (no setContext/getContext required)
2
+ // This removes the constraint that hooks must be called at component init time.
2
3
 
3
- import { getContext, setContext } from 'svelte';
4
4
  import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
5
5
  import type { RouterProvider } from './router-provider';
6
6
 
7
- const DATA_PROVIDER_KEY = Symbol('data-provider');
8
- const AUTH_PROVIDER_KEY = Symbol('auth-provider');
9
- const RESOURCES_KEY = Symbol('resources');
10
- const ROUTER_PROVIDER_KEY = Symbol('router-provider');
11
-
12
7
  // ─── DataProvider (supports single or multiple) ─────────────────
13
8
 
14
9
  /**
@@ -18,16 +13,17 @@ const ROUTER_PROVIDER_KEY = Symbol('router-provider');
18
13
  */
19
14
  export type DataProviderInput = DataProvider | Record<string, DataProvider>;
20
15
 
16
+ let providers = $state<Record<string, DataProvider> | null>(null);
17
+
21
18
  export function setDataProvider(provider: DataProviderInput): void {
22
19
  if (isDataProvider(provider)) {
23
- setContext(DATA_PROVIDER_KEY, { default: provider } as Record<string, DataProvider>);
20
+ providers = { default: provider } as Record<string, DataProvider>;
24
21
  } else {
25
- setContext(DATA_PROVIDER_KEY, provider as Record<string, DataProvider>);
22
+ providers = provider as Record<string, DataProvider>;
26
23
  }
27
24
  }
28
25
 
29
26
  export function getDataProvider(name?: string): DataProvider {
30
- const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
31
27
  if (!providers) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
32
28
  const key = name ?? 'default';
33
29
  const provider = providers[key];
@@ -38,7 +34,6 @@ export function getDataProvider(name?: string): DataProvider {
38
34
  }
39
35
 
40
36
  export function getDataProviderNames(): string[] {
41
- const providers = getContext<Record<string, DataProvider>>(DATA_PROVIDER_KEY);
42
37
  return providers ? Object.keys(providers) : [];
43
38
  }
44
39
 
@@ -71,41 +66,58 @@ function isDataProvider(value: unknown): value is DataProvider {
71
66
 
72
67
  // ─── Auth Provider ──────────────────────────────────────────────
73
68
 
69
+ let authProvider = $state<AuthProvider | null>(null);
70
+
74
71
  export function setAuthProvider(provider: AuthProvider): void {
75
- setContext(AUTH_PROVIDER_KEY, provider);
72
+ authProvider = provider;
76
73
  }
77
74
 
78
75
  export function getAuthProvider(): AuthProvider {
79
- const provider = getContext<AuthProvider>(AUTH_PROVIDER_KEY);
80
- if (!provider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
81
- return provider;
76
+ if (!authProvider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
77
+ return authProvider;
82
78
  }
83
79
 
84
80
  // ─── Resources ──────────────────────────────────────────────────
85
81
 
86
- export function setResources(resources: ResourceDefinition[]): void {
87
- setContext(RESOURCES_KEY, resources);
82
+ let resources = $state.raw<ResourceDefinition[]>([]);
83
+
84
+ export function setResources(newResources: ResourceDefinition[]): void {
85
+ resources = newResources;
88
86
  }
89
87
 
90
88
  export function getResources(): ResourceDefinition[] {
91
- const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
92
- if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
89
+ if (!resources || resources.length === 0) throw new Error('Resources not found. Did you call setResources in App.svelte?');
93
90
  return resources;
94
91
  }
95
92
 
96
93
  export function getResource(nameOrIdentifier: string): ResourceDefinition {
97
- const resources = getResources();
98
- const resource = resources.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
94
+ const rList = getResources();
95
+ const resource = rList.find(r => r.identifier === nameOrIdentifier || r.name === nameOrIdentifier);
99
96
  if (!resource) throw new Error(`Resource "${nameOrIdentifier}" not found in resource definitions.`);
100
97
  return resource;
101
98
  }
102
99
 
103
100
  // ─── Router Provider ────────────────────────────────────────────
104
101
 
102
+ let routerProvider = $state<RouterProvider | undefined>(undefined);
103
+
105
104
  export function setRouterProvider(provider: RouterProvider): void {
106
- setContext(ROUTER_PROVIDER_KEY, provider);
105
+ routerProvider = provider;
107
106
  }
108
107
 
109
108
  export function getRouterProvider(): RouterProvider | undefined {
110
- return getContext<RouterProvider>(ROUTER_PROVIDER_KEY);
109
+ return routerProvider;
110
+ }
111
+
112
+
113
+ // ─── Live Provider ──────────────────────────────────────────────
114
+
115
+ let liveProviderState: import('./live').LiveProvider | undefined = undefined;
116
+
117
+ export function setLiveProvider(provider: import('./live').LiveProvider): void {
118
+ liveProviderState = provider;
119
+ }
120
+
121
+ export function getLiveProvider(): import('./live').LiveProvider | undefined {
122
+ return liveProviderState;
111
123
  }
@@ -1,4 +1,4 @@
1
- import { getDataProviderForResource } from './context';
1
+ import { getDataProviderForResource } from './context.svelte';
2
2
  import { useParsed } from './useParsed';
3
3
  import type { GetListResult, Sort, Filter, BaseRecord } from './types';
4
4
 
@@ -1,7 +1,7 @@
1
1
  import { useQueryClient } from '@tanstack/svelte-query';
2
2
  import { useParsed } from './useParsed';
3
3
  import { getAdminOptions } from './options';
4
- import { getDataProviderForResource } from './context';
4
+ import { getDataProviderForResource } from './context.svelte';
5
5
  import { createQuery, createMutation } from '@tanstack/svelte-query';
6
6
  import { toast } from './toast.svelte';
7
7
  import { t } from './i18n.svelte';
@@ -103,7 +103,7 @@ export function useForm<
103
103
  let errors = $state<Record<string, string>>({});
104
104
  function setFieldError(field: string, message: string) { errors = { ...errors, [field]: message }; }
105
105
  function clearErrors() { errors = {}; }
106
- function clearFieldError(field: string) { const next = { ...errors }; delete next[field]; errors = next; }
106
+ function clearFieldError(field: string) { delete errors[field]; }
107
107
 
108
108
  function runValidation(values: TVariables): boolean {
109
109
  clearErrors();
@@ -178,14 +178,14 @@ export function useForm<
178
178
  async function onFinish(values: TVariables, finishOptions?: { redirect?: 'list' | 'edit' | 'show' | false }) {
179
179
  if (!runValidation(values)) { toast.warning(t('validation.required')); return; }
180
180
  redirectOverride = finishOptions?.redirect;
181
- if (action === 'create' || action === 'clone') await (createMut as any).mutateAsync(values);
182
- else await (updateMut as any).mutateAsync(values);
181
+ if (action === 'create' || action === 'clone') await createMut.mutateAsync(values);
182
+ else await updateMut.mutateAsync(values);
183
183
  }
184
184
 
185
185
  async function onFinishAutoSave(values: TVariables) {
186
186
  if (action === 'create' || action === 'clone') return; // Only autosave on edit
187
187
  redirectOverride = false; // Never redirect on autoSave
188
- await (updateMut as any).mutateAsync(values);
188
+ await updateMut.mutateAsync(values);
189
189
  }
190
190
 
191
191
  let autoSaveStatus = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
@@ -244,7 +244,7 @@ export function useForm<
244
244
 
245
245
  return {
246
246
  query,
247
- get formLoading() { return ((query as any)?.isLoading ?? false) || (createMut as any).isPending || (updateMut as any).isPending; },
247
+ get formLoading() { return (query?.isLoading ?? false) || createMut.isPending || updateMut.isPending; },
248
248
  mutation: action === 'edit' ? updateMut : createMut,
249
249
  onFinish,
250
250
  onFinishAutoSave,
@@ -23,7 +23,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
23
23
 
24
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
25
  import { getAdminOptions } from './options';
26
- import { getDataProviderForResource, getDataProvider } from './context';
26
+ import { getDataProviderForResource, getDataProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed';
28
28
  import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
29
  import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
@@ -203,7 +203,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
203
203
  return provider.custom<TData>({
204
204
  url: options.url,
205
205
  method: options.method,
206
- payload: options.config?.payload as any,
206
+ payload: options.config?.payload,
207
207
  query: options.config?.query,
208
208
  headers: options.config?.headers,
209
209
  sorters: options.config?.sorters,
@@ -229,7 +229,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
229
229
  const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
230
230
  mutationFn: async (params) => {
231
231
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
232
- return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values as any, meta: params.meta });
232
+ return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
233
233
  },
234
234
  }));
235
235
 
@@ -38,6 +38,9 @@ const locales: Record<string, Locale> = {
38
38
  'common.home': '首页',
39
39
  'common.logout': '退出登录',
40
40
  'common.toggleTheme': '切换主题',
41
+ 'common.switchLanguage': '切换语言',
42
+ 'common.clear': '清除',
43
+ 'common.menu': '菜单',
41
44
  'common.darkMode': '暗色模式',
42
45
  'common.lightMode': '亮色模式',
43
46
  'common.detail': '详情',
@@ -47,19 +50,39 @@ const locales: Record<string, Locale> = {
47
50
  'common.retry': '重试',
48
51
  'common.yes': '是',
49
52
  'common.no': '否',
53
+ 'common.undo': '撤销',
54
+ 'common.filter': '筛选',
55
+ 'common.reset': '重置',
50
56
  'common.welcome': '欢迎使用 {title}',
51
57
  'common.dashboardHint': '从侧边栏选择一个资源开始。',
52
58
  'common.redirecting': '正在跳转到登录页...',
59
+ 'common.expand': '展开',
60
+ 'common.collapse': '收起',
61
+ 'common.close': '关闭',
62
+ 'common.copy': '复制',
63
+ 'common.copyAll': '复制全部',
64
+ 'common.copied': '已复制',
65
+ 'common.next': '下一步',
66
+ 'common.back': '返回',
67
+ 'common.step': '步骤',
68
+ 'common.columns': '列',
69
+ 'common.perPage': '每页',
70
+ 'common.copyId': '复制 ID',
71
+ 'common.showMore': '展开更多',
72
+ 'common.showLess': '收起',
53
73
  // Field
54
74
  'field.enterValue': '输入{label}',
55
75
  'field.selectPlaceholder': '请选择',
56
76
  'field.tagsPlaceholder': '输入标签后按回车...',
77
+ 'field.noOptions': '无可用选项',
78
+ 'field.addImage': '添加图片',
57
79
  // Config errors
58
80
  'config.missingEnvTitle': '环境配置缺失',
59
81
  'config.missingEnvDescription': '应用无法启动,因为缺少必要的环境变量。',
60
82
  'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
61
83
  'config.envFilePath': '文件路径: .env',
62
84
  'config.reload': '配置完成后刷新页面',
85
+ 'config.reloadButton': '重新加载页面',
63
86
  // Auth pages
64
87
  'auth.login': '登录',
65
88
  'auth.register': '注册',
@@ -86,6 +109,17 @@ const locales: Record<string, Locale> = {
86
109
  'auth.welcomeMessage': '登录以继续使用管理后台',
87
110
  'auth.createAccount': '创建账号',
88
111
  'auth.createAccountMessage': '填写信息以创建您的账号',
112
+ // AutoSave
113
+ 'autoSave.idle': '已保存',
114
+ 'autoSave.saving': '保存中...',
115
+ 'autoSave.saved': '已保存',
116
+ 'autoSave.error': '保存失败',
117
+ // Empty states
118
+ 'empty.title': '暂无数据',
119
+ 'empty.description': '没有找到符合条件的记录。',
120
+ 'empty.noMore': '没有更多了',
121
+ // DevTools
122
+ 'devtools.title': '开发者工具 (Ctrl+Shift+D)',
89
123
  // Validation
90
124
  'validation.required': '此字段为必填项',
91
125
  'validation.minLength': '最少 {min} 个字符',
@@ -128,6 +162,9 @@ const locales: Record<string, Locale> = {
128
162
  'common.home': 'Home',
129
163
  'common.logout': 'Log out',
130
164
  'common.toggleTheme': 'Toggle theme',
165
+ 'common.switchLanguage': 'Switch language',
166
+ 'common.clear': 'Clear',
167
+ 'common.menu': 'Menu',
131
168
  'common.darkMode': 'Dark mode',
132
169
  'common.lightMode': 'Light mode',
133
170
  'common.detail': 'Detail',
@@ -137,19 +174,39 @@ const locales: Record<string, Locale> = {
137
174
  'common.retry': 'Retry',
138
175
  'common.yes': 'Yes',
139
176
  'common.no': 'No',
177
+ 'common.undo': 'Undo',
178
+ 'common.filter': 'Filter',
179
+ 'common.reset': 'Reset',
140
180
  'common.welcome': 'Welcome to {title}',
141
181
  'common.dashboardHint': 'Select a resource from the sidebar to get started.',
142
182
  'common.redirecting': 'Redirecting to login...',
183
+ 'common.expand': 'Expand',
184
+ 'common.collapse': 'Collapse',
185
+ 'common.close': 'Close',
186
+ 'common.copy': 'Copy',
187
+ 'common.copyAll': 'Copy All',
188
+ 'common.copied': 'Copied!',
189
+ 'common.next': 'Next',
190
+ 'common.back': 'Back',
191
+ 'common.step': 'Step',
192
+ 'common.columns': 'Columns',
193
+ 'common.perPage': 'per page',
194
+ 'common.copyId': 'Copy ID',
195
+ 'common.showMore': 'Show more',
196
+ 'common.showLess': 'Show less',
143
197
  // Field
144
198
  'field.enterValue': 'Enter {label}',
145
199
  'field.selectPlaceholder': 'Select...',
146
200
  'field.tagsPlaceholder': 'Type a tag and press Enter...',
201
+ 'field.noOptions': 'No options available',
202
+ 'field.addImage': 'Add image',
147
203
  // Config errors
148
204
  'config.missingEnvTitle': 'Missing Configuration',
149
205
  'config.missingEnvDescription': 'The app cannot start because required environment variables are missing.',
150
206
  'config.addToEnvFile': 'Add the following to your .env file:',
151
207
  'config.envFilePath': 'File path: .env',
152
- 'config.reload': 'Reload after configuration',
208
+ 'config.reload': 'Refresh page after configuration',
209
+ 'config.reloadButton': 'Reload Page',
153
210
  // Auth pages
154
211
  'auth.login': 'Login',
155
212
  'auth.register': 'Register',
@@ -176,6 +233,17 @@ const locales: Record<string, Locale> = {
176
233
  'auth.welcomeMessage': 'Sign in to continue to admin panel',
177
234
  'auth.createAccount': 'Create Account',
178
235
  'auth.createAccountMessage': 'Fill in your details to create an account',
236
+ // AutoSave
237
+ 'autoSave.idle': 'Saved',
238
+ 'autoSave.saving': 'Saving...',
239
+ 'autoSave.saved': 'Saved',
240
+ 'autoSave.error': 'Save failed',
241
+ // Empty states
242
+ 'empty.title': 'No data',
243
+ 'empty.description': 'No records found matching your criteria.',
244
+ 'empty.noMore': 'No more items',
245
+ // DevTools
246
+ 'devtools.title': 'DevTools (Ctrl+Shift+D)',
179
247
  // Validation
180
248
  'validation.required': 'This field is required',
181
249
  'validation.minLength': 'Minimum {min} characters',
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@ export {
5
5
  setAuthProvider, getAuthProvider,
6
6
  setResources, getResources, getResource,
7
7
  setRouterProvider, getRouterProvider,
8
- } from './context';
9
- export type { DataProviderInput } from './context';
8
+ } from './context.svelte';
9
+ export type { DataProviderInput } from './context.svelte';
10
10
  export {
11
11
  useList, useInfiniteList,
12
12
  useOne, useShow,
@@ -92,3 +92,5 @@ export {
92
92
  unionFilters, unionSorters,
93
93
  file2Base64, generateDefaultDocumentTitle,
94
94
  } from './helpers';
95
+ export { TableState } from './table-state.svelte';
96
+ export type { TableStateOptions } from './table-state.svelte';
@@ -1,6 +1,6 @@
1
1
  import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options';
3
- import { getDataProviderForResource } from './context';
3
+ import { getDataProviderForResource } from './context.svelte';
4
4
  import { useParsed } from './useParsed';
5
5
  import { audit } from './audit';
6
6
  import { UndoError } from './types';
@@ -1,6 +1,6 @@
1
1
  import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query';
2
2
  import { getAdminOptions } from './options';
3
- import { getDataProviderForResource, getDataProvider } from './context';
3
+ import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
4
4
  import { useParsed } from './useParsed';
5
5
  import {
6
6
  createOvertimeTracker,
@@ -64,7 +64,7 @@ export function useList<TData extends BaseRecord = BaseRecord, TError = HttpErro
64
64
 
65
65
  createLiveSubscription({
66
66
  resource,
67
- liveProvider: (globalThis as any).__svadminLiveProvider, // Handle via context ideally, or skip if not needed. Wait, in live.ts we pass liveProvider. Let's fix this in hook-utils.
67
+ liveProvider: getLiveProvider(),
68
68
  liveMode: options.liveMode ?? adminOptions.liveMode,
69
69
  onLiveEvent: (e) => {
70
70
  options.onLiveEvent?.(e);
@@ -1,5 +1,5 @@
1
1
  import { currentPath, navigate } from './router';
2
- import { getResource, getResources, getRouterProvider } from './context';
2
+ import { getResource, getResources, getRouterProvider } from './context.svelte';
3
3
  import { useParsed } from './useParsed';
4
4
 
5
5
  // ─── routing-hooks.svelte.ts ───────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  import { useParsed } from './useParsed';
2
2
  import { useList } from './query-hooks.svelte';
3
3
  import { readURLState, writeURLState } from './url-sync';
4
- import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources } from './types';
4
+ import type { Pagination, Sort, Filter, BaseRecord, HttpError, KnownResources, GetListResult } from './types';
5
5
  import type { UseListOptions } from './query-hooks.svelte';
6
6
 
7
7
  export type FilterSetMode = 'merge' | 'replace';
@@ -148,15 +148,15 @@ export function useTable<
148
148
  createLinkForSyncWithLocation,
149
149
  get totalPages() {
150
150
  // ts-expect-error reactive access
151
- const total = (query as any).data?.total ?? 0;
151
+ const total = (query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0;
152
152
  return Math.ceil(total / (pagination.pageSize ?? 10));
153
153
  },
154
154
  get current() { return pagination.current ?? 1; },
155
155
  get pageSize() { return pagination.pageSize ?? 10; },
156
- get pageCount() { return Math.ceil(((query as any).data?.total ?? 0) / (pagination.pageSize ?? 10)); },
156
+ get pageCount() { return Math.ceil(((query.data as GetListResult<TQueryFnData> | undefined)?.total ?? 0) / (pagination.pageSize ?? 10)); },
157
157
  get clientData(): TQueryFnData[] {
158
158
  if (paginationMode !== 'client') return [];
159
- const allData = ((query as any).data?.data ?? []) as TQueryFnData[];
159
+ const allData = ((query.data as GetListResult<TQueryFnData> | undefined)?.data ?? []) as TQueryFnData[];
160
160
  const start = ((pagination.current ?? 1) - 1) * (pagination.pageSize ?? 10);
161
161
  const end = start + (pagination.pageSize ?? 10);
162
162
  let sorted = [...allData];
@@ -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,4 +1,7 @@
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.
3
+
4
+ import { toast as sonner } from 'svelte-sonner';
2
5
 
3
6
  export type ToastType = 'success' | 'error' | 'info' | 'warning' | 'undoable';
4
7
 
@@ -11,6 +14,7 @@ export interface Toast {
11
14
  onTimeout?: () => void;
12
15
  }
13
16
 
17
+ // Legacy compatibility — kept for existing consumers
14
18
  let nextId = 0;
15
19
  let toasts = $state<Toast[]>([]);
16
20
 
@@ -19,23 +23,35 @@ export function getToasts(): Toast[] {
19
23
  }
20
24
 
21
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;
35
+ }
36
+
37
+ // Undoable toasts still use custom system
22
38
  const id = nextId++;
23
39
  toasts = [...toasts, { id, type, message, duration, ...options }];
24
- if (duration > 0 && type !== 'undoable') {
25
- setTimeout(() => removeToast(id), duration);
26
- }
27
40
  }
28
41
 
29
42
  export function removeToast(id: number): void {
30
43
  toasts = toasts.filter(t => t.id !== id);
31
44
  }
32
45
 
33
- // Convenience methods
46
+ // Convenience methods — backward compatible
34
47
  export const toast = {
35
48
  success: (msg: string, duration?: number) => addToast('success', msg, duration),
36
49
  error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
37
50
  info: (msg: string, duration?: number) => addToast('info', msg, duration),
38
51
  warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
39
- undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
52
+ undoable: (msg: string, duration: number, onUndo: () => void, onTimeout: () => void) =>
40
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),
41
57
  };
package/src/types.ts CHANGED
@@ -255,6 +255,8 @@ export interface ResourceDefinition {
255
255
  showInMenu?: boolean;
256
256
  parentName?: string;
257
257
  menuOrder?: number;
258
+ /** Navigation group name — resources with the same group are displayed in a collapsible section */
259
+ group?: string;
258
260
  meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
259
261
  }
260
262
 
@@ -266,6 +268,7 @@ export interface FieldDefinition {
266
268
  required?: boolean;
267
269
  searchable?: boolean;
268
270
  sortable?: boolean;
271
+ filterable?: boolean;
269
272
  width?: string;
270
273
  showInList?: boolean;
271
274
  showInForm?: boolean;
@@ -280,6 +283,8 @@ export interface FieldDefinition {
280
283
  optionValue?: string; // field to use as value
281
284
  // Validation
282
285
  validate?: (value: unknown) => string | null;
286
+ // Grouping
287
+ group?: string;
283
288
  }
284
289
 
285
290
  // ─── Resource Type Registry ───────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  // UnsavedChangesNotifier — warns user before leaving page with unsaved changes
2
2
  // Svelte 5 component using runes
3
3
 
4
- import { onDestroy, onMount } from 'svelte';
4
+ // No lifecycle imports needed — uses module-level $state pattern
5
5
 
6
6
  let warnWhenUnsavedChanges = $state(false);
7
7
 
package/src/useParsed.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // useParsed — parse current URL hash into structured route info
2
2
 
3
3
  import { currentPath } from './router';
4
- import { getResources } from './context';
4
+ import { getResources } from './context.svelte';
5
5
 
6
6
  interface ParsedRoute {
7
7
  resource?: string;
@@ -35,7 +35,7 @@ export function useStepsForm<
35
35
  TError = HttpError,
36
36
  TVariables = Record<string, unknown>,
37
37
  TData extends BaseRecord = TQueryFnData
38
- >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as any): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
38
+ >(options: UseStepsFormOptions<TQueryFnData, TError, TVariables, TData> = {} as UseStepsFormOptions<TQueryFnData, TError, TVariables, TData>): UseStepsFormResult<TQueryFnData, TError, TVariables, TData> {
39
39
  const form = useForm<TQueryFnData, TError, TVariables, TData>(options);
40
40
 
41
41
  let currentStep = $state(options.defaultStep ?? 0);
@@ -1,7 +1,7 @@
1
1
  import { useForm, type UseFormOptions } from './form-hooks.svelte';
2
2
  import { useQueryClient } from '@tanstack/svelte-query';
3
3
  import { useParsed } from './useParsed';
4
- import { getResource, getResources } from './context';
4
+ import { getResource, getResources } from './context.svelte';
5
5
  import { getAdminOptions } from './options';
6
6
  import { navigate } from './router';
7
7
  import { t } from './i18n.svelte';