@svadmin/core 0.0.1 → 0.0.3

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.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Core SDK — hooks, types, context, i18n, permissions, router",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -0,0 +1,139 @@
1
+ // Data transfer utilities — CSV export/import
2
+
3
+ import { getDataProvider } from './context';
4
+ import type { GetListResult } from './types';
5
+
6
+ /**
7
+ * useExport — export all records from a resource as CSV download
8
+ */
9
+ export function useExport(resource: string) {
10
+ let isExporting = $state(false);
11
+
12
+ async function exportCSV(opts?: { filename?: string; fields?: string[] }) {
13
+ const provider = getDataProvider();
14
+ isExporting = true;
15
+
16
+ try {
17
+ // Fetch all records (up to 10000)
18
+ const result: GetListResult = await provider.getList({
19
+ resource,
20
+ pagination: { current: 1, pageSize: 10000 },
21
+ });
22
+
23
+ if (result.data.length === 0) return;
24
+
25
+ const records = result.data as Record<string, unknown>[];
26
+ const fields = opts?.fields ?? Object.keys(records[0]);
27
+
28
+ // Build CSV
29
+ const header = fields.join(',');
30
+ const rows = records.map(record =>
31
+ fields.map(f => {
32
+ const val = record[f];
33
+ const str = val === null || val === undefined ? '' : String(val);
34
+ // Escape commas and quotes
35
+ return str.includes(',') || str.includes('"') || str.includes('\n')
36
+ ? `"${str.replace(/"/g, '""')}"`
37
+ : str;
38
+ }).join(',')
39
+ );
40
+
41
+ const csv = [header, ...rows].join('\n');
42
+ const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
43
+ const url = URL.createObjectURL(blob);
44
+ const a = document.createElement('a');
45
+ a.href = url;
46
+ a.download = opts?.filename ?? `${resource}_export.csv`;
47
+ a.click();
48
+ URL.revokeObjectURL(url);
49
+ } finally {
50
+ isExporting = false;
51
+ }
52
+ }
53
+
54
+ return {
55
+ exportCSV,
56
+ get isExporting() { return isExporting; },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * useImport — import records from a CSV file
62
+ */
63
+ export function useImport(resource: string) {
64
+ let isImporting = $state(false);
65
+ let importResult = $state<{ success: number; failed: number } | null>(null);
66
+
67
+ async function importCSV(file: File): Promise<{ success: number; failed: number }> {
68
+ const provider = getDataProvider();
69
+ isImporting = true;
70
+ let success = 0;
71
+ let failed = 0;
72
+
73
+ try {
74
+ const text = await file.text();
75
+ const lines = text.split('\n').filter(l => l.trim() !== '');
76
+ if (lines.length < 2) return { success: 0, failed: 0 };
77
+
78
+ const headers = parseCSVLine(lines[0]);
79
+
80
+ for (let i = 1; i < lines.length; i++) {
81
+ const values = parseCSVLine(lines[i]);
82
+ const record: Record<string, unknown> = {};
83
+ headers.forEach((h, idx) => {
84
+ record[h] = values[idx] ?? '';
85
+ });
86
+
87
+ try {
88
+ await provider.create({ resource, variables: record });
89
+ success++;
90
+ } catch {
91
+ failed++;
92
+ }
93
+ }
94
+
95
+ importResult = { success, failed };
96
+ return { success, failed };
97
+ } finally {
98
+ isImporting = false;
99
+ }
100
+ }
101
+
102
+ return {
103
+ importCSV,
104
+ get isImporting() { return isImporting; },
105
+ get importResult() { return importResult; },
106
+ };
107
+ }
108
+
109
+ /** Parse a single CSV line respecting quoted fields */
110
+ function parseCSVLine(line: string): string[] {
111
+ const result: string[] = [];
112
+ let current = '';
113
+ let inQuotes = false;
114
+
115
+ for (let i = 0; i < line.length; i++) {
116
+ const ch = line[i];
117
+ if (inQuotes) {
118
+ if (ch === '"' && line[i + 1] === '"') {
119
+ current += '"';
120
+ i++;
121
+ } else if (ch === '"') {
122
+ inQuotes = false;
123
+ } else {
124
+ current += ch;
125
+ }
126
+ } else {
127
+ if (ch === '"') {
128
+ inQuotes = true;
129
+ } else if (ch === ',') {
130
+ result.push(current.trim());
131
+ current = '';
132
+ } else {
133
+ current += ch;
134
+ }
135
+ }
136
+ }
137
+ result.push(current.trim());
138
+ return result;
139
+ }
@@ -13,6 +13,7 @@ import { toast } from './toast.svelte';
13
13
  import { audit } from './audit';
14
14
  import { navigate } from './router';
15
15
  import { t } from './i18n.svelte';
16
+ import { readURLState, writeURLState } from './url-sync';
16
17
 
17
18
  // ─── useList ────────────────────────────────────────────────────
18
19
 
@@ -391,12 +392,42 @@ interface UseFormOptions {
391
392
  onMutationSuccess?: (data: unknown) => void;
392
393
  onMutationError?: (error: Error) => void;
393
394
  meta?: Record<string, unknown>;
395
+ validate?: (values: Record<string, unknown>) => Record<string, string> | null;
394
396
  }
395
397
 
396
398
  export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
397
399
  const provider = getDataProvider();
398
400
  const queryClient = useQueryClient();
399
- const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta } = options;
401
+ const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta, validate } = options;
402
+
403
+ // Validation state
404
+ let errors = $state<Record<string, string>>({});
405
+
406
+ function setFieldError(field: string, message: string) {
407
+ errors = { ...errors, [field]: message };
408
+ }
409
+
410
+ function clearErrors() {
411
+ errors = {};
412
+ }
413
+
414
+ function clearFieldError(field: string) {
415
+ const next = { ...errors };
416
+ delete next[field];
417
+ errors = next;
418
+ }
419
+
420
+ function runValidation(values: Record<string, unknown>): boolean {
421
+ clearErrors();
422
+ if (validate) {
423
+ const result = validate(values);
424
+ if (result && Object.keys(result).length > 0) {
425
+ errors = result;
426
+ return false;
427
+ }
428
+ }
429
+ return true;
430
+ }
400
431
 
401
432
  // Fetch existing data for edit/clone
402
433
  const query = (action === 'edit' || action === 'clone') && id != null
@@ -450,6 +481,12 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
450
481
  }
451
482
 
452
483
  async function onFinish(values: Record<string, unknown>) {
484
+ // Run validation before submitting
485
+ if (!runValidation(values)) {
486
+ toast.warning(t('validation.required'));
487
+ return;
488
+ }
489
+
453
490
  if (action === 'create' || action === 'clone') {
454
491
  // @ts-expect-error $ rune prefix — Svelte compiler transforms this
455
492
  await $createMut.mutateAsync(values);
@@ -467,6 +504,10 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
467
504
  },
468
505
  mutation: action === 'edit' ? updateMut : createMut,
469
506
  onFinish,
507
+ get errors() { return errors; },
508
+ setFieldError,
509
+ clearErrors,
510
+ clearFieldError,
470
511
  };
471
512
  }
472
513
 
@@ -483,10 +524,27 @@ interface UseTableOptions {
483
524
  }
484
525
 
485
526
  export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
486
- const { resource, meta } = options;
527
+ const { resource, meta, syncWithLocation = false } = options;
528
+
529
+ // Read initial state from URL if syncWithLocation
530
+ let initialPagination = options.pagination ?? { current: 1, pageSize: 10 };
531
+ let initialSorters = options.sorters ?? [];
532
+
533
+ if (syncWithLocation) {
534
+ const urlState = readURLState();
535
+ if (urlState.page || urlState.pageSize) {
536
+ initialPagination = {
537
+ current: urlState.page ?? initialPagination.current,
538
+ pageSize: urlState.pageSize ?? initialPagination.pageSize,
539
+ };
540
+ }
541
+ if (urlState.sortField) {
542
+ initialSorters = [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }];
543
+ }
544
+ }
487
545
 
488
- let pagination = $state<Pagination>(options.pagination ?? { current: 1, pageSize: 10 });
489
- let sorters = $state<Sort[]>(options.sorters ?? []);
546
+ let pagination = $state<Pagination>(initialPagination);
547
+ let sorters = $state<Sort[]>(initialSorters);
490
548
  let filters = $state<Filter[]>(options.filters ?? []);
491
549
 
492
550
  const query = useList<T>({ resource, pagination, sorters, filters, meta });
@@ -496,6 +554,18 @@ export function useTable<T = Record<string, unknown>>(options: UseTableOptions)
496
554
  function setPage(page: number) { pagination = { ...pagination, current: page }; }
497
555
  function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
498
556
 
557
+ // Sync state to URL when syncWithLocation is enabled
558
+ if (syncWithLocation) {
559
+ $effect(() => {
560
+ writeURLState({
561
+ page: pagination.current,
562
+ pageSize: pagination.pageSize,
563
+ sortField: sorters[0]?.field,
564
+ sortOrder: sorters[0]?.order,
565
+ });
566
+ });
567
+ }
568
+
499
569
  return {
500
570
  query,
501
571
  pagination,
@@ -56,6 +56,38 @@ const locales: Record<string, Locale> = {
56
56
  'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
57
57
  'config.envFilePath': '文件路径: .env',
58
58
  'config.reload': '配置完成后刷新页面',
59
+ // Auth pages
60
+ 'auth.login': '登录',
61
+ 'auth.register': '注册',
62
+ 'auth.forgotPassword': '忘记密码',
63
+ 'auth.resetPassword': '重置密码',
64
+ 'auth.email': '邮箱',
65
+ 'auth.password': '密码',
66
+ 'auth.confirmPassword': '确认密码',
67
+ 'auth.rememberMe': '记住我',
68
+ 'auth.loginButton': '登录',
69
+ 'auth.registerButton': '注册',
70
+ 'auth.sendResetLink': '发送重置链接',
71
+ 'auth.backToLogin': '返回登录',
72
+ 'auth.noAccount': '还没有账号?',
73
+ 'auth.hasAccount': '已有账号?',
74
+ 'auth.forgotPasswordLink': '忘记密码?',
75
+ 'auth.forgotPasswordDescription': '输入您的邮箱地址,我们将发送重置密码的链接。',
76
+ 'auth.resetLinkSent': '重置链接已发送,请查看您的邮箱。',
77
+ 'auth.passwordMismatch': '两次输入的密码不一致',
78
+ 'auth.emailRequired': '请输入邮箱',
79
+ 'auth.passwordRequired': '请输入密码',
80
+ 'auth.registerSuccess': '注册成功',
81
+ 'auth.welcomeBack': '欢迎回来',
82
+ 'auth.welcomeMessage': '登录以继续使用管理后台',
83
+ 'auth.createAccount': '创建账号',
84
+ 'auth.createAccountMessage': '填写信息以创建您的账号',
85
+ // Validation
86
+ 'validation.required': '此字段为必填项',
87
+ 'validation.minLength': '最少 {min} 个字符',
88
+ 'validation.maxLength': '最多 {max} 个字符',
89
+ 'validation.invalidEmail': '请输入有效的邮箱地址',
90
+ 'validation.invalidFormat': '格式不正确',
59
91
  },
60
92
  'en': {
61
93
  // Common
@@ -110,6 +142,38 @@ const locales: Record<string, Locale> = {
110
142
  'config.addToEnvFile': 'Add the following to your .env file:',
111
143
  'config.envFilePath': 'File path: .env',
112
144
  'config.reload': 'Reload after configuration',
145
+ // Auth pages
146
+ 'auth.login': 'Login',
147
+ 'auth.register': 'Register',
148
+ 'auth.forgotPassword': 'Forgot Password',
149
+ 'auth.resetPassword': 'Reset Password',
150
+ 'auth.email': 'Email',
151
+ 'auth.password': 'Password',
152
+ 'auth.confirmPassword': 'Confirm Password',
153
+ 'auth.rememberMe': 'Remember me',
154
+ 'auth.loginButton': 'Sign In',
155
+ 'auth.registerButton': 'Sign Up',
156
+ 'auth.sendResetLink': 'Send Reset Link',
157
+ 'auth.backToLogin': 'Back to Login',
158
+ 'auth.noAccount': "Don't have an account?",
159
+ 'auth.hasAccount': 'Already have an account?',
160
+ 'auth.forgotPasswordLink': 'Forgot password?',
161
+ 'auth.forgotPasswordDescription': 'Enter your email address and we will send you a reset link.',
162
+ 'auth.resetLinkSent': 'Reset link sent! Check your email.',
163
+ 'auth.passwordMismatch': 'Passwords do not match',
164
+ 'auth.emailRequired': 'Email is required',
165
+ 'auth.passwordRequired': 'Password is required',
166
+ 'auth.registerSuccess': 'Registration successful',
167
+ 'auth.welcomeBack': 'Welcome Back',
168
+ 'auth.welcomeMessage': 'Sign in to continue to admin panel',
169
+ 'auth.createAccount': 'Create Account',
170
+ 'auth.createAccountMessage': 'Fill in your details to create an account',
171
+ // Validation
172
+ 'validation.required': 'This field is required',
173
+ 'validation.minLength': 'Minimum {min} characters',
174
+ 'validation.maxLength': 'Maximum {max} characters',
175
+ 'validation.invalidEmail': 'Please enter a valid email address',
176
+ 'validation.invalidFormat': 'Invalid format',
113
177
  },
114
178
  };
115
179
 
package/src/index.ts CHANGED
@@ -22,10 +22,11 @@ export { readURLState, writeURLState } from './url-sync';
22
22
  export { setAccessControl, canAccess, canAccessAsync } from './permissions';
23
23
  export { useLive } from './live';
24
24
  export { toast } from './toast.svelte';
25
+ export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
25
26
  export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
26
27
  export { audit, setAuditHandler } from './audit';
27
- export { getTheme, setTheme, toggleTheme, getResolvedTheme } from './theme.svelte';
28
- export type { ThemeMode } from './theme.svelte';
28
+ export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
29
+ export type { ThemeMode, ColorTheme } from './theme.svelte';
29
30
 
30
31
  export type {
31
32
  DataProvider, AuthProvider, NotificationProvider, MutationMode,
@@ -46,3 +47,5 @@ export type {
46
47
  export type { LiveProvider, LiveEvent } from './live';
47
48
  export type { Action, AccessControlResult, AccessControlFn } from './permissions';
48
49
  export type { AuditEntry, AuditHandler } from './audit';
50
+ export { useCan } from './useCan';
51
+ export { useExport, useImport } from './data-transfer';
@@ -0,0 +1,45 @@
1
+ // Notification Provider — pluggable notification system
2
+ // Falls back to built-in toast when no provider is set
3
+
4
+ import { toast } from './toast.svelte';
5
+ import type { NotificationProvider } from './types';
6
+
7
+ let notificationProvider: NotificationProvider | null = null;
8
+
9
+ export function setNotificationProvider(provider: NotificationProvider): void {
10
+ notificationProvider = provider;
11
+ }
12
+
13
+ export function getNotificationProvider(): NotificationProvider | null {
14
+ return notificationProvider;
15
+ }
16
+
17
+ /**
18
+ * Send a notification through the configured provider, or fall back to toast.
19
+ */
20
+ export function notify(params: {
21
+ type: 'success' | 'error' | 'warning' | 'info';
22
+ message: string;
23
+ description?: string;
24
+ key?: string;
25
+ }): void {
26
+ if (notificationProvider) {
27
+ notificationProvider.open(params);
28
+ } else {
29
+ // Fallback to built-in toast
30
+ const { type, message } = params;
31
+ switch (type) {
32
+ case 'success': toast.success(message); break;
33
+ case 'error': toast.error(message); break;
34
+ case 'warning': toast.warning(message); break;
35
+ case 'info': toast.info(message); break;
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Close a notification by key (only works with custom provider).
42
+ */
43
+ export function closeNotification(key: string): void {
44
+ notificationProvider?.close(key);
45
+ }
@@ -1,8 +1,22 @@
1
- // Theme — dark/light/system mode management (Svelte 5 runes)
1
+ // Theme — dark/light/system mode + color theme management (Svelte 5 runes)
2
2
 
3
3
  export type ThemeMode = 'light' | 'dark' | 'system';
4
+ export type ColorTheme = 'blue' | 'green' | 'rose' | 'orange' | 'violet' | 'zinc';
4
5
 
5
6
  const STORAGE_KEY = 'svadmin-theme';
7
+ const COLOR_STORAGE_KEY = 'svadmin-color-theme';
8
+
9
+ // ── Color themes (display metadata) ──────────────────────
10
+ export const colorThemes: { id: ColorTheme; label: string; color: string }[] = [
11
+ { id: 'blue', label: 'Blue', color: '#3b82f6' },
12
+ { id: 'green', label: 'Green', color: '#22c55e' },
13
+ { id: 'rose', label: 'Rose', color: '#f43f5e' },
14
+ { id: 'orange', label: 'Orange', color: '#f97316' },
15
+ { id: 'violet', label: 'Violet', color: '#8b5cf6' },
16
+ { id: 'zinc', label: 'Zinc', color: '#71717a' },
17
+ ];
18
+
19
+ // ── Dark/Light mode ──────────────────────────────────────
6
20
 
7
21
  function getStoredTheme(): ThemeMode {
8
22
  if (typeof localStorage === 'undefined') return 'system';
@@ -54,3 +68,33 @@ export function toggleTheme(): void {
54
68
  export function getResolvedTheme(): 'light' | 'dark' {
55
69
  return mode === 'system' ? getSystemPreference() : mode;
56
70
  }
71
+
72
+ // ── Color theme ──────────────────────────────────────────
73
+
74
+ function getStoredColorTheme(): ColorTheme {
75
+ if (typeof localStorage === 'undefined') return 'blue';
76
+ return (localStorage.getItem(COLOR_STORAGE_KEY) as ColorTheme) ?? 'blue';
77
+ }
78
+
79
+ let colorTheme = $state<ColorTheme>(getStoredColorTheme());
80
+
81
+ function applyColorTheme(ct: ColorTheme): void {
82
+ if (typeof document !== 'undefined') {
83
+ document.documentElement.setAttribute('data-theme', ct);
84
+ }
85
+ }
86
+
87
+ // Apply on init
88
+ applyColorTheme(colorTheme);
89
+
90
+ export function getColorTheme(): ColorTheme {
91
+ return colorTheme;
92
+ }
93
+
94
+ export function setColorTheme(ct: ColorTheme): void {
95
+ colorTheme = ct;
96
+ if (typeof localStorage !== 'undefined') {
97
+ localStorage.setItem(COLOR_STORAGE_KEY, ct);
98
+ }
99
+ applyColorTheme(ct);
100
+ }
package/src/types.ts CHANGED
@@ -233,4 +233,6 @@ export interface FieldDefinition {
233
233
  resource?: string; // related resource name
234
234
  optionLabel?: string; // field to use as label
235
235
  optionValue?: string; // field to use as value
236
+ // Validation
237
+ validate?: (value: unknown) => string | null;
236
238
  }
package/src/useCan.ts ADDED
@@ -0,0 +1,37 @@
1
+ // useCan — reactive permission check hook
2
+
3
+ import { canAccessAsync } from './permissions';
4
+ import type { Action, AccessControlResult } from './permissions';
5
+
6
+ interface UseCanResult {
7
+ allowed: boolean;
8
+ reason?: string;
9
+ isLoading: boolean;
10
+ }
11
+
12
+ /**
13
+ * Reactive permission check. Calls canAccessAsync() and returns reactive state.
14
+ * Usage: const can = useCan('posts', 'delete');
15
+ * if (can.allowed) { ... }
16
+ */
17
+ export function useCan(resource: string, action: Action, params?: Record<string, unknown>): UseCanResult {
18
+ let allowed = $state(true);
19
+ let reason = $state<string | undefined>(undefined);
20
+ let isLoading = $state(true);
21
+
22
+ // Run the async check
23
+ canAccessAsync(resource, action, params).then((result: AccessControlResult) => {
24
+ allowed = result.can;
25
+ reason = result.reason;
26
+ isLoading = false;
27
+ }).catch(() => {
28
+ allowed = true; // default to allowed on error
29
+ isLoading = false;
30
+ });
31
+
32
+ return {
33
+ get allowed() { return allowed; },
34
+ get reason() { return reason; },
35
+ get isLoading() { return isLoading; },
36
+ };
37
+ }