@svadmin/lite 0.1.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.
- package/README.md +149 -0
- package/package.json +38 -0
- package/src/components/LiteAlert.svelte +19 -0
- package/src/components/LiteForm.svelte +138 -0
- package/src/components/LiteLayout.svelte +58 -0
- package/src/components/LiteLogin.svelte +41 -0
- package/src/components/LitePagination.svelte +73 -0
- package/src/components/LiteSearch.svelte +41 -0
- package/src/components/LiteShow.svelte +96 -0
- package/src/components/LiteTable.svelte +132 -0
- package/src/index.ts +32 -0
- package/src/lite.css +460 -0
- package/src/schema-generator.ts +145 -0
- package/src/server-adapter.ts +291 -0
- package/static/enhance.js +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# @svadmin/lite
|
|
2
|
+
|
|
3
|
+
**Lightweight, SSR-compatible admin UI for [@svadmin](https://github.com/zuohuadong/svadmin).**
|
|
4
|
+
|
|
5
|
+
Zero client-side JavaScript required. Works in IE11 and legacy browsers.
|
|
6
|
+
|
|
7
|
+
## Why?
|
|
8
|
+
|
|
9
|
+
The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5, Tailwind CSS v4, and TanStack.
|
|
10
|
+
However, some enterprise/government environments require IE11 compatibility.
|
|
11
|
+
|
|
12
|
+
`@svadmin/lite` provides a server-rendered fallback that **shares the same DataProvider, AuthProvider, and Resource definitions** — only the rendering layer is different.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
| Feature | How it works |
|
|
17
|
+
|---------|-------------|
|
|
18
|
+
| **List page** | Server-rendered `<table>` with `<a>` sort links |
|
|
19
|
+
| **Detail page** | Key-value layout with type-aware formatting |
|
|
20
|
+
| **Create/Edit** | Native `<form method="POST">` with server-side validation |
|
|
21
|
+
| **Delete** | `<details>` confirmation, no JS needed |
|
|
22
|
+
| **Search** | `<form method="GET">` with `?q=` parameter |
|
|
23
|
+
| **Pagination** | Pure `<a>` page links |
|
|
24
|
+
| **Login/Logout** | Cookie-based auth via SvelteKit Form Actions |
|
|
25
|
+
| **Auth Guard** | Server hook redirects unauthenticated users |
|
|
26
|
+
| **UA Detection** | Auto-redirect IE11 users to `/lite/` routes |
|
|
27
|
+
| **i18n** | Uses `@svadmin/core` `t()` translations |
|
|
28
|
+
| **Print** | `@media print` optimized styles |
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
### 1. Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add @svadmin/lite
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Create a list page
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// src/routes/lite/posts/+page.server.ts
|
|
42
|
+
import { createListLoader, createCrudActions } from '@svadmin/lite';
|
|
43
|
+
import { dataProvider, resources } from '$lib/admin';
|
|
44
|
+
|
|
45
|
+
const postsResource = resources.find(r => r.name === 'posts')!;
|
|
46
|
+
|
|
47
|
+
export const load = createListLoader(dataProvider, postsResource);
|
|
48
|
+
export const actions = createCrudActions(dataProvider, postsResource);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```svelte
|
|
52
|
+
<!-- src/routes/lite/posts/+page.svelte -->
|
|
53
|
+
<script lang="ts">
|
|
54
|
+
export const csr = false; // ← Critical: disable client-side rendering
|
|
55
|
+
|
|
56
|
+
import { LiteLayout, LiteTable, LitePagination, LiteSearch, LiteAlert } from '@svadmin/lite';
|
|
57
|
+
import '@svadmin/lite/src/lite.css';
|
|
58
|
+
import { resources } from '$lib/admin';
|
|
59
|
+
|
|
60
|
+
let { data, form } = $props();
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<LiteLayout resources={resources} currentResource="posts" brandName="My Admin">
|
|
64
|
+
<div class="lite-header">
|
|
65
|
+
<h1>{data.resource.label}</h1>
|
|
66
|
+
<a href="/lite/posts/create" class="lite-btn lite-btn-primary">+ Create</a>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{#if form?.success}
|
|
70
|
+
<LiteAlert type="success" message="Operation completed!" />
|
|
71
|
+
{/if}
|
|
72
|
+
|
|
73
|
+
<LiteSearch value={data.search} />
|
|
74
|
+
<LiteTable
|
|
75
|
+
records={data.records}
|
|
76
|
+
resource={data.resource}
|
|
77
|
+
currentSort={data.sort}
|
|
78
|
+
currentOrder={data.order}
|
|
79
|
+
/>
|
|
80
|
+
<LitePagination page={data.page} totalPages={data.totalPages} />
|
|
81
|
+
</LiteLayout>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Auto-redirect legacy browsers
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
// src/hooks.server.ts
|
|
88
|
+
import { createLegacyRedirectHook } from '@svadmin/lite';
|
|
89
|
+
|
|
90
|
+
export const handle = createLegacyRedirectHook('/lite');
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 4. Add the `<meta>` tags for dual-core browsers
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<!-- src/app.html -->
|
|
97
|
+
<head>
|
|
98
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
|
99
|
+
<meta name="renderer" content="webkit">
|
|
100
|
+
<meta charset="utf-8">
|
|
101
|
+
</head>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Components
|
|
105
|
+
|
|
106
|
+
| Component | Description |
|
|
107
|
+
|-----------|-------------|
|
|
108
|
+
| `LiteLayout` | Sidebar + main area layout |
|
|
109
|
+
| `LiteTable` | HTML table with sort links and delete confirmation |
|
|
110
|
+
| `LiteSearch` | GET-based search form |
|
|
111
|
+
| `LitePagination` | Page number links |
|
|
112
|
+
| `LiteForm` | Auto-generated form from `FieldDefinition[]` |
|
|
113
|
+
| `LiteShow` | Record detail/view page |
|
|
114
|
+
| `LiteLogin` | Login form |
|
|
115
|
+
| `LiteAlert` | Success/error notification banner |
|
|
116
|
+
|
|
117
|
+
## Server Utilities
|
|
118
|
+
|
|
119
|
+
| Function | Description |
|
|
120
|
+
|----------|-------------|
|
|
121
|
+
| `createListLoader(dp, resource)` | SvelteKit `load` function for list pages |
|
|
122
|
+
| `createDetailLoader(dp, resource)` | SvelteKit `load` function for detail pages |
|
|
123
|
+
| `createCrudActions(dp, resource)` | SvelteKit form actions for create/update/delete |
|
|
124
|
+
| `createAuthGuard(authProvider)` | Server hook for authentication |
|
|
125
|
+
| `createAuthActions(authProvider)` | Login/logout form actions |
|
|
126
|
+
| `createLegacyRedirectHook()` | Auto-redirect IE11 to `/lite/` |
|
|
127
|
+
| `fieldsToZodSchema(fields)` | Auto-generate Zod schema for superforms |
|
|
128
|
+
|
|
129
|
+
## CSS
|
|
130
|
+
|
|
131
|
+
Import `@svadmin/lite/src/lite.css` in your layout. It's fully self-contained:
|
|
132
|
+
- No CSS variables
|
|
133
|
+
- No modern CSS features
|
|
134
|
+
- Vendor-prefixed flexbox for IE11
|
|
135
|
+
- Print-optimized styles
|
|
136
|
+
- 350 lines, ~10KB unminified
|
|
137
|
+
|
|
138
|
+
## Optional: Progressive Enhancement
|
|
139
|
+
|
|
140
|
+
Copy `packages/lite/static/enhance.js` to your static folder. This ES5 script adds:
|
|
141
|
+
- Auto-close delete confirmation when clicking outside
|
|
142
|
+
- Auto-focus first form input
|
|
143
|
+
- Unsaved changes warning
|
|
144
|
+
|
|
145
|
+
**100% optional** — everything works without it.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svadmin/lite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SSR-compatible lightweight admin UI for @svadmin — zero client-side JS, works in IE11",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/**/*",
|
|
8
|
+
"static/**/*"
|
|
9
|
+
],
|
|
10
|
+
"main": "src/index.ts",
|
|
11
|
+
"types": "src/index.ts",
|
|
12
|
+
"svelte": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"svelte": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./enhance.js": "./static/enhance.js",
|
|
20
|
+
"./src/lite.css": "./src/lite.css"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"svelte": "^5.0.0",
|
|
24
|
+
"@svadmin/core": "^0.5.0",
|
|
25
|
+
"@sveltejs/kit": "^2.0.0"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"sveltekit-superforms": "^2.22.0",
|
|
29
|
+
"zod": "^3.24.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "zuohuadong",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/zuohuadong/svadmin.git",
|
|
36
|
+
"directory": "packages/lite"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteAlert — Success/error feedback banner for form actions.
|
|
4
|
+
* Renders from SvelteKit form action results.
|
|
5
|
+
*/
|
|
6
|
+
interface Props {
|
|
7
|
+
type: 'success' | 'error' | 'info';
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { type, message }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#if message}
|
|
15
|
+
<div class="lite-alert {type === 'success' ? 'lite-alert-success' : type === 'error' ? 'lite-alert-error' : ''}">
|
|
16
|
+
{#if type === 'success'}✓{:else if type === 'error'}✗{:else}ℹ{/if}
|
|
17
|
+
{message}
|
|
18
|
+
</div>
|
|
19
|
+
{/if}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteForm — Server-rendered form driven by FieldDefinitions.
|
|
4
|
+
* Uses native <form> with method="POST" for IE11 compatibility.
|
|
5
|
+
* Integrates with sveltekit-superforms for server-side validation.
|
|
6
|
+
*/
|
|
7
|
+
import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
|
|
8
|
+
import { t } from '@svadmin/core/i18n';
|
|
9
|
+
import { fieldToInputType, fieldToPlaceholder } from '../schema-generator';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
fields: FieldDefinition[];
|
|
13
|
+
/** Resource definition (used for primaryKey) */
|
|
14
|
+
resource?: ResourceDefinition;
|
|
15
|
+
/** Existing values (for edit mode) */
|
|
16
|
+
values?: Record<string, unknown>;
|
|
17
|
+
/** Validation errors from server */
|
|
18
|
+
errors?: Record<string, string[]>;
|
|
19
|
+
/** 'create' or 'edit' */
|
|
20
|
+
mode?: 'create' | 'edit';
|
|
21
|
+
/** Form action URL */
|
|
22
|
+
action?: string;
|
|
23
|
+
/** Submit button label */
|
|
24
|
+
submitLabel?: string;
|
|
25
|
+
/** Cancel URL */
|
|
26
|
+
cancelUrl?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
fields,
|
|
31
|
+
resource,
|
|
32
|
+
values = {},
|
|
33
|
+
errors = {},
|
|
34
|
+
mode = 'create',
|
|
35
|
+
action = '',
|
|
36
|
+
submitLabel,
|
|
37
|
+
cancelUrl,
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const formFields = $derived(
|
|
41
|
+
fields.filter(f => {
|
|
42
|
+
if (f.showInForm === false) return false;
|
|
43
|
+
if (mode === 'create' && f.showInCreate === false) return false;
|
|
44
|
+
if (mode === 'edit' && f.showInEdit === false) return false;
|
|
45
|
+
return true;
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<form method="POST" action={action || undefined} class="lite-card" enctype="multipart/form-data">
|
|
51
|
+
{#if mode === 'edit'}
|
|
52
|
+
{@const pk = resource?.primaryKey ?? 'id'}
|
|
53
|
+
{#if values[pk] != null}
|
|
54
|
+
<input type="hidden" name="_id" value={String(values[pk])} />
|
|
55
|
+
{/if}
|
|
56
|
+
{/if}
|
|
57
|
+
|
|
58
|
+
{#each formFields as field}
|
|
59
|
+
{@const inputType = fieldToInputType(field)}
|
|
60
|
+
{@const placeholder = fieldToPlaceholder(field)}
|
|
61
|
+
{@const value = values[field.key]}
|
|
62
|
+
{@const fieldErrors = errors[field.key]}
|
|
63
|
+
{@const hasError = fieldErrors && fieldErrors.length > 0}
|
|
64
|
+
|
|
65
|
+
<div class="lite-form-group">
|
|
66
|
+
{#if inputType === 'checkbox'}
|
|
67
|
+
<div class="lite-checkbox-group">
|
|
68
|
+
<input
|
|
69
|
+
type="checkbox"
|
|
70
|
+
name={field.key}
|
|
71
|
+
id={field.key}
|
|
72
|
+
checked={!!value}
|
|
73
|
+
/>
|
|
74
|
+
<label for={field.key}>{field.label}</label>
|
|
75
|
+
</div>
|
|
76
|
+
{:else}
|
|
77
|
+
<label for={field.key}>
|
|
78
|
+
{field.label}
|
|
79
|
+
{#if field.required}<span class="required">*</span>{/if}
|
|
80
|
+
</label>
|
|
81
|
+
|
|
82
|
+
{#if inputType === 'textarea'}
|
|
83
|
+
<textarea
|
|
84
|
+
name={field.key}
|
|
85
|
+
id={field.key}
|
|
86
|
+
class="lite-input {hasError ? 'lite-input-error' : ''}"
|
|
87
|
+
placeholder={placeholder}
|
|
88
|
+
>{value ?? ''}</textarea>
|
|
89
|
+
{:else if inputType === 'select'}
|
|
90
|
+
<select
|
|
91
|
+
name={field.key}
|
|
92
|
+
id={field.key}
|
|
93
|
+
class="lite-select {hasError ? 'lite-input-error' : ''}"
|
|
94
|
+
{...field.type === 'multiselect' ? { multiple: true } : {}}
|
|
95
|
+
>
|
|
96
|
+
{#if field.type !== 'multiselect'}
|
|
97
|
+
<option value="">-- {t('common.select') || 'Select'} --</option>
|
|
98
|
+
{/if}
|
|
99
|
+
{#if field.options}
|
|
100
|
+
{#each field.options as opt}
|
|
101
|
+
<option
|
|
102
|
+
value={String(opt.value)}
|
|
103
|
+
selected={field.type === 'multiselect' ? (Array.isArray(value) && value.includes(opt.value)) : value === opt.value}
|
|
104
|
+
>
|
|
105
|
+
{opt.label}
|
|
106
|
+
</option>
|
|
107
|
+
{/each}
|
|
108
|
+
{/if}
|
|
109
|
+
</select>
|
|
110
|
+
{:else}
|
|
111
|
+
<input
|
|
112
|
+
type={inputType}
|
|
113
|
+
name={field.key}
|
|
114
|
+
id={field.key}
|
|
115
|
+
value={String(value ?? '')}
|
|
116
|
+
class="lite-input {hasError ? 'lite-input-error' : ''}"
|
|
117
|
+
placeholder={placeholder}
|
|
118
|
+
{... field.required ? { required: true } : {}}
|
|
119
|
+
{... field.type === 'images' ? { multiple: true } : {}}
|
|
120
|
+
/>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
{#if hasError}
|
|
124
|
+
{#each fieldErrors as err}
|
|
125
|
+
<div class="lite-error-text">{err}</div>
|
|
126
|
+
{/each}
|
|
127
|
+
{/if}
|
|
128
|
+
{/if}
|
|
129
|
+
</div>
|
|
130
|
+
{/each}
|
|
131
|
+
|
|
132
|
+
<div style="display:flex;align-items:center;margin-top:20px;">
|
|
133
|
+
<button type="submit" class="lite-btn lite-btn-primary">{submitLabel || t('common.save') || 'Save'}</button>
|
|
134
|
+
{#if cancelUrl}
|
|
135
|
+
<a href={cancelUrl} class="lite-btn" style="margin-left:8px;">{t('common.cancel') || 'Cancel'}</a>
|
|
136
|
+
{/if}
|
|
137
|
+
</div>
|
|
138
|
+
</form>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteLayout — Minimal server-rendered sidebar + main layout.
|
|
4
|
+
* No client-side JS required. Uses only IE11-safe CSS classes.
|
|
5
|
+
*/
|
|
6
|
+
import type { ResourceDefinition } from '@svadmin/core';
|
|
7
|
+
import { t } from '@svadmin/core/i18n';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
resources: ResourceDefinition[];
|
|
11
|
+
currentResource?: string;
|
|
12
|
+
brandName?: string;
|
|
13
|
+
userName?: string;
|
|
14
|
+
basePath?: string;
|
|
15
|
+
children: any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
resources,
|
|
20
|
+
currentResource = '',
|
|
21
|
+
brandName = 'Admin',
|
|
22
|
+
userName = '',
|
|
23
|
+
basePath = '/lite',
|
|
24
|
+
children,
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
const menuResources = $derived(
|
|
28
|
+
resources.filter(r => r.showInMenu !== false)
|
|
29
|
+
);
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class="lite-wrapper">
|
|
33
|
+
<!-- Sidebar -->
|
|
34
|
+
<nav class="lite-sidebar">
|
|
35
|
+
<div class="lite-sidebar-brand">{brandName}</div>
|
|
36
|
+
{#each menuResources as res}
|
|
37
|
+
<a
|
|
38
|
+
href="{basePath}/{res.name}"
|
|
39
|
+
class={res.name === currentResource ? 'active' : ''}
|
|
40
|
+
>
|
|
41
|
+
{res.label}
|
|
42
|
+
</a>
|
|
43
|
+
{/each}
|
|
44
|
+
{#if userName}
|
|
45
|
+
<div style="position:absolute;bottom:0;left:0;right:0;padding:12px 16px;border-top:1px solid #334155;font-size:12px;color:#94a3b8;">
|
|
46
|
+
{userName}
|
|
47
|
+
<form method="POST" action="{basePath}/login?/logout" style="display:inline;margin-left:8px;">
|
|
48
|
+
<button type="submit" class="lite-btn lite-btn-sm" style="color:#94a3b8;border-color:#475569;background:transparent;">Logout</button>
|
|
49
|
+
</form>
|
|
50
|
+
</div>
|
|
51
|
+
{/if}
|
|
52
|
+
</nav>
|
|
53
|
+
|
|
54
|
+
<!-- Main Content -->
|
|
55
|
+
<main class="lite-main">
|
|
56
|
+
{@render children()}
|
|
57
|
+
</main>
|
|
58
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteLogin — Simple login form for SSR.
|
|
4
|
+
* Posts to SvelteKit form action, no JS needed.
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@svadmin/core/i18n';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
error?: string;
|
|
10
|
+
brandName?: string;
|
|
11
|
+
action?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { error, brandName = 'Admin', action = '?/login' }: Props = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="lite-login-wrapper">
|
|
18
|
+
<div class="lite-login-card">
|
|
19
|
+
<div class="lite-card">
|
|
20
|
+
<h2 style="text-align:center;margin:0 0 20px;font-size:20px;font-weight:600;">{brandName}</h2>
|
|
21
|
+
|
|
22
|
+
{#if error}
|
|
23
|
+
<div class="lite-alert lite-alert-error">{error}</div>
|
|
24
|
+
{/if}
|
|
25
|
+
|
|
26
|
+
<form method="POST" action={action}>
|
|
27
|
+
<div class="lite-form-group">
|
|
28
|
+
<label for="email">{t('auth.email') || 'Email'}</label>
|
|
29
|
+
<input type="text" name="email" id="email" class="lite-input" placeholder="user@example.com" required />
|
|
30
|
+
</div>
|
|
31
|
+
<div class="lite-form-group">
|
|
32
|
+
<label for="password">{t('auth.password') || 'Password'}</label>
|
|
33
|
+
<input type="password" name="password" id="password" class="lite-input" required />
|
|
34
|
+
</div>
|
|
35
|
+
<button type="submit" class="lite-btn lite-btn-primary" style="width:100%;padding:10px;">
|
|
36
|
+
{t('auth.signIn') || 'Sign In'}
|
|
37
|
+
</button>
|
|
38
|
+
</form>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LitePagination — Pure <a> tag pagination for SSR.
|
|
4
|
+
* No JavaScript. All page transitions are full page navigations.
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@svadmin/core/i18n';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
page: number;
|
|
10
|
+
totalPages: number;
|
|
11
|
+
/** Base URL that page numbers will be appended to */
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
/** Additional parameters to preserve (like sort, order, q) */
|
|
14
|
+
preserveParams?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { page, totalPages, baseUrl = '', preserveParams = {} }: Props = $props();
|
|
18
|
+
|
|
19
|
+
// Show max 7 page links
|
|
20
|
+
const visiblePages = $derived.by(() => {
|
|
21
|
+
const pages: (number | '...')[] = [];
|
|
22
|
+
if (totalPages <= 7) {
|
|
23
|
+
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
|
24
|
+
} else {
|
|
25
|
+
pages.push(1);
|
|
26
|
+
if (page > 3) pages.push('...');
|
|
27
|
+
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
|
|
28
|
+
pages.push(i);
|
|
29
|
+
}
|
|
30
|
+
if (page < totalPages - 2) pages.push('...');
|
|
31
|
+
pages.push(totalPages);
|
|
32
|
+
}
|
|
33
|
+
return pages;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function pageUrl(p: number): string {
|
|
37
|
+
const params = new URLSearchParams(preserveParams);
|
|
38
|
+
params.set('page', String(p));
|
|
39
|
+
const qs = params.toString();
|
|
40
|
+
const sep = baseUrl.includes('?') ? '&' : '?';
|
|
41
|
+
return `${baseUrl}${baseUrl && !baseUrl.includes('?') ? '?' : sep}${qs}`;
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
{#if totalPages > 1}
|
|
46
|
+
<nav class="lite-pagination">
|
|
47
|
+
{#if page > 1}
|
|
48
|
+
<a href={pageUrl(page - 1)}>← {t('common.prev') || 'Prev'}</a>
|
|
49
|
+
{:else}
|
|
50
|
+
<span class="disabled">← {t('common.prev') || 'Prev'}</span>
|
|
51
|
+
{/if}
|
|
52
|
+
|
|
53
|
+
{#each visiblePages as p}
|
|
54
|
+
{#if p === '...'}
|
|
55
|
+
<span class="disabled">…</span>
|
|
56
|
+
{:else if p === page}
|
|
57
|
+
<span class="active">{p}</span>
|
|
58
|
+
{:else}
|
|
59
|
+
<a href={pageUrl(p)}>{p}</a>
|
|
60
|
+
{/if}
|
|
61
|
+
{/each}
|
|
62
|
+
|
|
63
|
+
{#if page < totalPages}
|
|
64
|
+
<a href={pageUrl(page + 1)}>{t('common.next') || 'Next'} →</a>
|
|
65
|
+
{:else}
|
|
66
|
+
<span class="disabled">{t('common.next') || 'Next'} →</span>
|
|
67
|
+
{/if}
|
|
68
|
+
|
|
69
|
+
<span class="lite-pagination-info">
|
|
70
|
+
{t('common.pageOf', { page, total: totalPages }) || `Page ${page} of ${totalPages}`}
|
|
71
|
+
</span>
|
|
72
|
+
</nav>
|
|
73
|
+
{/if}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteSearch — Search bar using native <form> GET submission.
|
|
4
|
+
* No JS needed — submits as ?q=searchterm via page navigation.
|
|
5
|
+
*/
|
|
6
|
+
import { t } from '@svadmin/core/i18n';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
value?: string;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Additional hidden fields to preserve (e.g. sort, order) */
|
|
12
|
+
preserveParams?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
value = '',
|
|
17
|
+
placeholder,
|
|
18
|
+
preserveParams = {},
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<form method="GET" class="lite-search">
|
|
23
|
+
{#each Object.entries(preserveParams) as [key, val]}
|
|
24
|
+
<input type="hidden" name={key} value={val} />
|
|
25
|
+
{/each}
|
|
26
|
+
<input
|
|
27
|
+
type="text"
|
|
28
|
+
name="q"
|
|
29
|
+
value={value}
|
|
30
|
+
class="lite-input"
|
|
31
|
+
placeholder={placeholder || t('common.search') || 'Search...'}
|
|
32
|
+
/>
|
|
33
|
+
<button type="submit" class="lite-btn lite-btn-primary">
|
|
34
|
+
{t('common.search') || 'Search'}
|
|
35
|
+
</button>
|
|
36
|
+
{#if value}
|
|
37
|
+
<a href="?" class="lite-btn" style="margin-left:4px;">
|
|
38
|
+
{t('common.clear') || 'Clear'}
|
|
39
|
+
</a>
|
|
40
|
+
{/if}
|
|
41
|
+
</form>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LiteShow — Detail/view page for a single record.
|
|
4
|
+
* Renders field labels and values in a key-value layout.
|
|
5
|
+
*/
|
|
6
|
+
import type { ResourceDefinition, FieldDefinition } from '@svadmin/core';
|
|
7
|
+
import { t } from '@svadmin/core/i18n';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
record: Record<string, unknown>;
|
|
11
|
+
resource: ResourceDefinition;
|
|
12
|
+
basePath?: string;
|
|
13
|
+
canEdit?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
record,
|
|
18
|
+
resource,
|
|
19
|
+
basePath = '/lite',
|
|
20
|
+
canEdit = true,
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
const pk = resource.primaryKey ?? 'id';
|
|
24
|
+
const id = record[pk];
|
|
25
|
+
const showFields = $derived(
|
|
26
|
+
resource.fields.filter(f => f.showInShow !== false && f.showInList !== false)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function formatValue(value: unknown, field: FieldDefinition): string {
|
|
30
|
+
if (value == null) return '—';
|
|
31
|
+
if (field.type === 'boolean') return value ? '✓' : '✗';
|
|
32
|
+
if (field.type === 'date') {
|
|
33
|
+
try { return new Date(value as string).toLocaleString(); } catch { return String(value); }
|
|
34
|
+
}
|
|
35
|
+
if (field.type === 'select' && field.options) {
|
|
36
|
+
const opt = field.options.find(o => o.value === value);
|
|
37
|
+
return opt?.label ?? String(value);
|
|
38
|
+
}
|
|
39
|
+
if (field.type === 'url') return String(value);
|
|
40
|
+
if (field.type === 'email') return String(value);
|
|
41
|
+
if (Array.isArray(value)) return value.join(', ');
|
|
42
|
+
if (typeof value === 'object') {
|
|
43
|
+
try { return JSON.stringify(value, null, 2); } catch { return String(value); }
|
|
44
|
+
}
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class="lite-card">
|
|
50
|
+
<div class="lite-header">
|
|
51
|
+
<h1>{resource.label} #{id}</h1>
|
|
52
|
+
<div>
|
|
53
|
+
{#if canEdit}
|
|
54
|
+
<a href="{basePath}/{resource.name}/edit/{id}" class="lite-btn lite-btn-primary">
|
|
55
|
+
{t('common.edit') || 'Edit'}
|
|
56
|
+
</a>
|
|
57
|
+
{/if}
|
|
58
|
+
<a href="{basePath}/{resource.name}" class="lite-btn" style="margin-left:8px;">
|
|
59
|
+
{t('common.backToList') || 'Back to List'}
|
|
60
|
+
</a>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<table class="lite-table">
|
|
65
|
+
<tbody>
|
|
66
|
+
{#each showFields as field}
|
|
67
|
+
{@const value = record[field.key]}
|
|
68
|
+
<tr>
|
|
69
|
+
<td style="width:200px;font-weight:600;color:#64748b;vertical-align:top;">
|
|
70
|
+
{field.label}
|
|
71
|
+
</td>
|
|
72
|
+
<td>
|
|
73
|
+
{#if field.type === 'boolean'}
|
|
74
|
+
<span class="lite-bool {value ? 'lite-bool-true' : ''}"></span>
|
|
75
|
+
{value ? '✓ Yes' : '✗ No'}
|
|
76
|
+
{:else if field.type === 'url' && value}
|
|
77
|
+
<a href={String(value)} target="_blank" rel="noopener">{value}</a>
|
|
78
|
+
{:else if field.type === 'email' && value}
|
|
79
|
+
<a href="mailto:{value}">{value}</a>
|
|
80
|
+
{:else if field.type === 'image' && value}
|
|
81
|
+
<img src={String(value)} alt={field.label} style="max-width:300px;max-height:200px;border-radius:4px;" />
|
|
82
|
+
{:else if field.type === 'tags' && Array.isArray(value)}
|
|
83
|
+
{#each value as tag}
|
|
84
|
+
<span class="lite-badge">{tag}</span>
|
|
85
|
+
{/each}
|
|
86
|
+
{:else if field.type === 'json' && value}
|
|
87
|
+
<pre style="margin:0;font-size:12px;background:#f8fafc;padding:8px;border-radius:4px;overflow-x:auto;">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>
|
|
88
|
+
{:else}
|
|
89
|
+
{formatValue(value, field)}
|
|
90
|
+
{/if}
|
|
91
|
+
</td>
|
|
92
|
+
</tr>
|
|
93
|
+
{/each}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</div>
|