@svadmin/lite 0.5.0 → 0.6.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.
Files changed (37) hide show
  1. package/README.md +105 -0
  2. package/dist/compatibility.d.ts +23 -0
  3. package/dist/compatibility.js +95 -0
  4. package/dist/components/LiteShowField.svelte +30 -22
  5. package/dist/components/LiteShowField.svelte.d.ts +3 -2
  6. package/dist/components/LiteTable.svelte +89 -36
  7. package/dist/components/LiteTable.svelte.d.ts +3 -2
  8. package/dist/components/compatibility/LiteCapabilityBoundary.svelte +51 -0
  9. package/dist/components/compatibility/LiteCapabilityBoundary.svelte.d.ts +13 -0
  10. package/dist/components/compatibility/LiteClipboardFallback.svelte +16 -0
  11. package/dist/components/compatibility/LiteClipboardFallback.svelte.d.ts +8 -0
  12. package/dist/components/compatibility/LiteComputeFallback.svelte +50 -0
  13. package/dist/components/compatibility/LiteComputeFallback.svelte.d.ts +14 -0
  14. package/dist/components/compatibility/LiteDirectoryUpload.svelte +44 -0
  15. package/dist/components/compatibility/LiteDirectoryUpload.svelte.d.ts +11 -0
  16. package/dist/components/compatibility/LiteOrderedList.svelte +47 -0
  17. package/dist/components/compatibility/LiteOrderedList.svelte.d.ts +14 -0
  18. package/dist/components/compatibility/LiteRealtimeStatus.svelte +43 -0
  19. package/dist/components/compatibility/LiteRealtimeStatus.svelte.d.ts +11 -0
  20. package/dist/components/compatibility/LiteVisualFallback.svelte +80 -0
  21. package/dist/components/compatibility/LiteVisualFallback.svelte.d.ts +18 -0
  22. package/dist/components/compatibility/index.d.ts +7 -0
  23. package/dist/components/compatibility/index.js +7 -0
  24. package/dist/components/pages/LiteCreatePage.svelte +15 -6
  25. package/dist/components/pages/LiteCreatePage.svelte.d.ts +1 -1
  26. package/dist/components/pages/LiteEditPage.svelte +18 -9
  27. package/dist/components/pages/LiteEditPage.svelte.d.ts +1 -1
  28. package/dist/components/pages/LiteListPage.svelte +86 -16
  29. package/dist/components/pages/LiteListPage.svelte.d.ts +4 -2
  30. package/dist/components/pages/LiteShowPage.svelte +19 -10
  31. package/dist/components/pages/LiteShowPage.svelte.d.ts +1 -1
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.js +5 -1
  34. package/dist/lite.css +239 -0
  35. package/dist/server-adapter.d.ts +18 -1
  36. package/dist/server-adapter.js +73 -16
  37. package/package.json +2 -2
package/README.md CHANGED
@@ -25,6 +25,8 @@ The main `@svadmin/ui` package delivers a premium SPA experience using Svelte 5,
25
25
  | **Authentication pages** | Login/logout plus optional provider-delegating account actions |
26
26
  | **Auth Guard** | Server hook redirects unauthenticated users |
27
27
  | **UA Detection** | Optional legacy-browser detection hook redirects users to `/lite/` routes |
28
+ | **SPA + IE11 split** | Modern browsers keep the existing SPA; IE11 is redirected before the SPA route is rendered |
29
+ | **Capability fallbacks** | Canvas, WASM, realtime, directory upload, observers, storage, media, and other browser-only features have server-safe alternatives |
28
30
  | **i18n** | Uses `@svadmin/core` `t()` translations |
29
31
  | **Multi-level Menu** | Always-expanded, config-driven nested links via `MenuItem[]` |
30
32
  | **Print** | `@media print` optimized styles |
@@ -195,6 +197,32 @@ export const handle = createLegacyRedirectHook('/lite');
195
197
  </head>
196
198
  ```
197
199
 
200
+ ### 5. Keep the SPA untouched while routing IE11 to Lite
201
+
202
+ The redirect belongs to the server hook or reverse proxy. It does not add a
203
+ compatibility branch, polyfill, or user-agent check to the SPA bundle:
204
+
205
+ ```typescript
206
+ // src/hooks.server.ts
207
+ import { createLegacyRedirectHook } from '@svadmin/lite';
208
+
209
+ export const handle = createLegacyRedirectHook({
210
+ litePrefix: '/lite',
211
+ spaPrefix: '/admin',
212
+ // Static assets, health checks, and API routes stay outside document routing.
213
+ exclude: ['/api', '/_app', '/health'],
214
+ });
215
+ ```
216
+
217
+ For a request such as `/admin/orders/show/7`, an IE11 user receives a 302 to
218
+ `/lite/orders/show/7`. Modern browsers continue to the existing SPA route.
219
+ The Lite subtree must remain `ssr = true` and `csr = false`; the browser never
220
+ executes Svelte 5 there.
221
+
222
+ If the application is behind a reverse proxy, the same rule can be implemented
223
+ there instead. The important property is that the SPA JavaScript is not sent to
224
+ IE11 before the decision is made.
225
+
198
226
  ## Components
199
227
 
200
228
  | Component | Description |
@@ -207,6 +235,13 @@ export const handle = createLegacyRedirectHook('/lite');
207
235
  | `LiteShow` | Record detail/view page |
208
236
  | `LiteLogin` | Login form |
209
237
  | `LiteAlert` | Success/error notification banner |
238
+ | `LiteCapabilityBoundary` | Documents and selects a server-safe fallback for an optional browser capability |
239
+ | `LiteVisualFallback` | Snapshot plus accessible table for Canvas, WebGL, map, chart, and flow UIs |
240
+ | `LiteDirectoryUpload` | Directory enhancement with multiple-file and ZIP upload fallbacks |
241
+ | `LiteRealtimeStatus` | Snapshot timestamp and native refresh/meta-refresh fallback for live data |
242
+ | `LiteComputeFallback` | Native POST action for WASM, Worker, and long-running compute fallback |
243
+ | `LiteOrderedList` | Up/down POST actions for drag-and-drop ordering fallback |
244
+ | `LiteClipboardFallback` | Selectable textarea when Clipboard API is unavailable |
210
245
 
211
246
  ## Server Utilities
212
247
 
@@ -243,6 +278,68 @@ Copy the exported `@svadmin/lite/enhance.js` asset to your application's static
243
278
 
244
279
  The asset is optional for core server-rendered navigation and form submission; the conveniences listed above require it.
245
280
 
281
+ ## Capability compatibility
282
+
283
+ The compatibility catalog is exported as `LITE_COMPATIBILITY_CATALOG`, with
284
+ `detectLiteCapabilities()` and `resolveLiteCompatibility()` for explicit client
285
+ enhancement entries. The detector accepts an injected environment, so SSR tests
286
+ do not need browser globals:
287
+
288
+ ```typescript
289
+ import {
290
+ detectLiteCapabilities,
291
+ resolveLiteCompatibility,
292
+ } from '@svadmin/lite';
293
+
294
+ const support = detectLiteCapabilities(globalThis);
295
+ const realtime = resolveLiteCompatibility('websocket', support);
296
+ ```
297
+
298
+ Do not import this detector into the modern SPA just to make IE11 work. The SPA
299
+ stays modern. Use it only in an optional Lite enhancement entry or a modern-only
300
+ Lite page. The normal Lite route requires no detector and no hydration.
301
+
302
+ Recommended fallback contract:
303
+
304
+ | Modern capability | Required Lite fallback |
305
+ |---|---|
306
+ | Canvas/WebGL/flow/map/chart | Static image or SVG plus a structured data table and download |
307
+ | WASM/Worker | Server action or background job with status and downloadable result |
308
+ | WebSocket/SSE | Snapshot timestamp, refresh link, optional polling or meta refresh |
309
+ | Directory/File System Access | Multiple files, relative paths where available, or ZIP upload |
310
+ | Virtual scrolling/IntersectionObserver | Server pagination or eager server rendering |
311
+ | Drag and drop | Ordered list with up/down POST actions |
312
+ | Clipboard API | Selectable text area and normal browser copy command |
313
+ | IndexedDB/localStorage | Server persistence for authoritative data; local cache only for preferences |
314
+ | Notifications/Media Capture/WebRTC | In-page status, file upload, or server-managed workflow |
315
+
316
+ These fallbacks preserve the business operation, submitted data, read access,
317
+ download, and auditability. They do not attempt to reproduce every modern
318
+ interaction pixel-for-pixel.
319
+
320
+ ### Third-party library boundaries
321
+
322
+ Do not patch these libraries into the IE11 document. Keep them in the modern SPA
323
+ and render the matching Lite fallback from shared records or server projections:
324
+
325
+ | Modern library family | Examples | Lite boundary |
326
+ |---|---|---|
327
+ | Flow/canvas/3D | `@xyflow/svelte`, Three.js, Fabric.js | `LiteVisualFallback` with nodes, edges, properties, snapshot, and export |
328
+ | Charts | ECharts, Chart.js, Vega | Lite chart components or `LiteVisualFallback` with a data table |
329
+ | Maps | MapLibre, Leaflet | Address/coordinate table, static map image, and external navigation link |
330
+ | Editors | Monaco, CodeMirror, TipTap | `textarea`, Markdown, source download, and server validation |
331
+ | Realtime clients | native WebSocket/EventSource, Socket.IO | `LiteRealtimeStatus`, refresh, polling endpoint, or server status page |
332
+ | File-system helpers | `browser-fs-access` | `LiteDirectoryUpload` and normal download links |
333
+ | Browser storage | `idb`, `idb-keyval`, localForage | Server persistence; browser cache only for drafts and preferences |
334
+ | Virtual table/drag libraries | TanStack Virtual, SortableJS | Server pagination and `LiteOrderedList` actions |
335
+ | PDF/media terminals | PDF.js, MediaRecorder, WebRTC, xterm.js | Download/upload, transcript/log view, and server workflow |
336
+
337
+ Dependency patch systems such as Bun `patchedDependencies` or `patch-package`
338
+ should only repair a reproducible package defect, such as an eager `window`
339
+ reference or incorrect package export. They must not be used to pretend that an
340
+ unavailable browser capability exists. Every retained patch needs an exact
341
+ package version, a regression test, and a documented removal condition.
342
+
246
343
  ## Compatibility notes
247
344
 
248
345
  - Registration, recovery, password, and profile actions delegate the submitted form
@@ -261,6 +358,14 @@ The asset is optional for core server-rendered navigation and form submission; t
261
358
  native links/forms and the IE11-safe Lite CSS contract. Keep the entire Lite route
262
359
  subtree on `ssr = true` and `csr = false`; Svelte 5 hydration/runtime execution in
263
360
  IE11 is not supported or promised by this package.
361
+ - The SPA is not an IE11 target. The supported architecture is `modern SPA +
362
+ server-routed Lite`, where server middleware decides which document bundle is
363
+ returned before the browser executes application JavaScript.
364
+ - Third-party polyfills are optional host concerns. `@vitejs/plugin-legacy`,
365
+ `core-js`, Fetch/EventSource polyfills, and `browser-fs-access` can improve a
366
+ modern-only enhancement entry, but they are not Lite core dependencies and do
367
+ not replace a server fallback for Canvas, WebSocket protocol support, WASM, or
368
+ File System Access.
264
369
 
265
370
  ## License
266
371
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Browser capability metadata for optional Lite enhancements.
3
+ *
4
+ * The SSR baseline never reads browser globals. Consumers may call
5
+ * `detectLiteCapabilities(globalThis)` from an explicitly client-only entry.
6
+ */
7
+ export type LiteCapability = 'canvas-2d' | 'webgl' | 'wasm' | 'wasm-streaming' | 'websocket' | 'event-source' | 'worker' | 'directory-upload' | 'file-system-access' | 'clipboard' | 'broadcast-channel' | 'intersection-observer' | 'resize-observer' | 'service-worker' | 'indexed-db' | 'notifications' | 'media-capture' | 'web-rtc' | 'geolocation' | 'web-streams';
8
+ export type LiteFallbackKind = 'structured-data' | 'static-snapshot' | 'server-action' | 'server-refresh' | 'server-pagination' | 'standard-upload' | 'manual-copy' | 'server-storage' | 'in-page-status' | 'download';
9
+ export interface LiteCompatibilityDescriptor {
10
+ capability: LiteCapability;
11
+ fallbackKind: LiteFallbackKind;
12
+ fallback: string;
13
+ enhancement: string;
14
+ }
15
+ export type LiteCapabilitySupport = Record<LiteCapability, boolean>;
16
+ export interface LiteCompatibilityResolution extends LiteCompatibilityDescriptor {
17
+ supported: boolean;
18
+ mode: 'enhanced' | 'fallback';
19
+ }
20
+ export declare const LITE_COMPATIBILITY_CATALOG: readonly LiteCompatibilityDescriptor[];
21
+ /** Detect optional browser APIs without accessing globals during SSR. */
22
+ export declare function detectLiteCapabilities(environment?: unknown): LiteCapabilitySupport;
23
+ export declare function resolveLiteCompatibility(capability: LiteCapability, support?: LiteCapabilitySupport): LiteCompatibilityResolution;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Browser capability metadata for optional Lite enhancements.
3
+ *
4
+ * The SSR baseline never reads browser globals. Consumers may call
5
+ * `detectLiteCapabilities(globalThis)` from an explicitly client-only entry.
6
+ */
7
+ function asRecord(value) {
8
+ return value !== null && (typeof value === 'object' || typeof value === 'function')
9
+ ? value
10
+ : undefined;
11
+ }
12
+ function hasFunction(record, key) {
13
+ return typeof record?.[key] === 'function';
14
+ }
15
+ function supportsCanvas(documentValue, context) {
16
+ const documentRecord = asRecord(documentValue);
17
+ if (!documentRecord || typeof documentRecord.createElement !== 'function')
18
+ return false;
19
+ const canvas = documentRecord.createElement('canvas');
20
+ const canvasRecord = asRecord(canvas);
21
+ if (!canvasRecord || typeof canvasRecord.getContext !== 'function')
22
+ return false;
23
+ return Boolean(canvasRecord.getContext(context));
24
+ }
25
+ function supportsDirectoryUpload(environment) {
26
+ const inputConstructor = asRecord(environment.HTMLInputElement);
27
+ const prototype = asRecord(inputConstructor?.prototype);
28
+ return prototype !== undefined && ('webkitdirectory' in prototype || 'directory' in prototype);
29
+ }
30
+ export const LITE_COMPATIBILITY_CATALOG = [
31
+ { capability: 'canvas-2d', fallbackKind: 'structured-data', fallback: 'Render a data table or static image.', enhancement: 'Load the Canvas UI in a modern client.' },
32
+ { capability: 'webgl', fallbackKind: 'static-snapshot', fallback: 'Render a server-generated image and structured values.', enhancement: 'Load the WebGL view in a modern client.' },
33
+ { capability: 'wasm', fallbackKind: 'server-action', fallback: 'Submit the task to a server action or background job.', enhancement: 'Run the WebAssembly module in a modern client.' },
34
+ { capability: 'wasm-streaming', fallbackKind: 'server-action', fallback: 'Use a server computation or downloadable result.', enhancement: 'Instantiate WebAssembly from a streamed response.' },
35
+ { capability: 'websocket', fallbackKind: 'server-refresh', fallback: 'Show the last snapshot with a native refresh link.', enhancement: 'Subscribe through WebSocket.' },
36
+ { capability: 'event-source', fallbackKind: 'server-refresh', fallback: 'Use refresh, polling, or a server status page.', enhancement: 'Subscribe through server-sent events.' },
37
+ { capability: 'worker', fallbackKind: 'server-action', fallback: 'Run work synchronously or as a server job.', enhancement: 'Move client computation into a Worker.' },
38
+ { capability: 'directory-upload', fallbackKind: 'standard-upload', fallback: 'Upload multiple files or a ZIP archive.', enhancement: 'Select a directory and preserve relative paths.' },
39
+ { capability: 'file-system-access', fallbackKind: 'standard-upload', fallback: 'Use standard file inputs and download links.', enhancement: 'Use the File System Access API.' },
40
+ { capability: 'clipboard', fallbackKind: 'manual-copy', fallback: 'Expose selectable text for manual copy and paste.', enhancement: 'Use the Clipboard API.' },
41
+ { capability: 'broadcast-channel', fallbackKind: 'server-refresh', fallback: 'Reload server-owned session state.', enhancement: 'Synchronize non-authoritative UI state between tabs.' },
42
+ { capability: 'intersection-observer', fallbackKind: 'server-pagination', fallback: 'Render eagerly or use server pagination.', enhancement: 'Lazy-load or virtualize visible content.' },
43
+ { capability: 'resize-observer', fallbackKind: 'structured-data', fallback: 'Use stable responsive dimensions and normal document flow.', enhancement: 'React to element-size changes.' },
44
+ { capability: 'service-worker', fallbackKind: 'download', fallback: 'Offer static snapshots and downloadable exports.', enhancement: 'Cache explicitly selected offline assets.' },
45
+ { capability: 'indexed-db', fallbackKind: 'server-storage', fallback: 'Persist authoritative state on the server.', enhancement: 'Cache drafts and non-authoritative preferences locally.' },
46
+ { capability: 'notifications', fallbackKind: 'in-page-status', fallback: 'Render an in-page alert and audit log.', enhancement: 'Show a browser notification after permission is granted.' },
47
+ { capability: 'media-capture', fallbackKind: 'standard-upload', fallback: 'Upload an existing image, audio, or video file.', enhancement: 'Capture media from the browser.' },
48
+ { capability: 'web-rtc', fallbackKind: 'standard-upload', fallback: 'Upload a recording or use a server-managed workflow.', enhancement: 'Enable a real-time peer media session.' },
49
+ { capability: 'geolocation', fallbackKind: 'structured-data', fallback: 'Enter an address or coordinates manually.', enhancement: 'Read the current location with user permission.' },
50
+ { capability: 'web-streams', fallbackKind: 'download', fallback: 'Use a normal request, response, upload, or download.', enhancement: 'Process streamed data in the modern client.' },
51
+ ];
52
+ const descriptorByCapability = new Map(LITE_COMPATIBILITY_CATALOG.map((descriptor) => [descriptor.capability, descriptor]));
53
+ /** Detect optional browser APIs without accessing globals during SSR. */
54
+ export function detectLiteCapabilities(environment) {
55
+ const explicitEnvironment = arguments.length > 0;
56
+ const env = asRecord(explicitEnvironment
57
+ ? environment
58
+ : typeof globalThis === 'undefined' ? undefined : globalThis) ?? {};
59
+ const navigator = asRecord(env.navigator);
60
+ const clipboard = asRecord(navigator?.clipboard);
61
+ const webAssembly = asRecord(env.WebAssembly);
62
+ const readableStream = asRecord(env.ReadableStream);
63
+ return {
64
+ 'canvas-2d': supportsCanvas(env.document, '2d'),
65
+ webgl: supportsCanvas(env.document, 'webgl'),
66
+ wasm: hasFunction(webAssembly, 'instantiate'),
67
+ 'wasm-streaming': hasFunction(webAssembly, 'instantiateStreaming'),
68
+ websocket: typeof env.WebSocket === 'function',
69
+ 'event-source': typeof env.EventSource === 'function',
70
+ worker: typeof env.Worker === 'function',
71
+ 'directory-upload': supportsDirectoryUpload(env),
72
+ 'file-system-access': typeof env.showOpenFilePicker === 'function'
73
+ || typeof env.showDirectoryPicker === 'function',
74
+ clipboard: hasFunction(clipboard, 'writeText'),
75
+ 'broadcast-channel': typeof env.BroadcastChannel === 'function',
76
+ 'intersection-observer': typeof env.IntersectionObserver === 'function',
77
+ 'resize-observer': typeof env.ResizeObserver === 'function',
78
+ 'service-worker': asRecord(navigator?.serviceWorker) !== undefined,
79
+ 'indexed-db': asRecord(env.indexedDB) !== undefined,
80
+ notifications: typeof env.Notification === 'function',
81
+ 'media-capture': hasFunction(asRecord(navigator?.mediaDevices), 'getUserMedia')
82
+ || typeof env.MediaRecorder === 'function',
83
+ 'web-rtc': typeof env.RTCPeerConnection === 'function',
84
+ geolocation: asRecord(navigator?.geolocation) !== undefined,
85
+ 'web-streams': asRecord(env.ReadableStream) !== undefined
86
+ && (hasFunction(readableStream, 'from') || typeof env.WritableStream === 'function'),
87
+ };
88
+ }
89
+ export function resolveLiteCompatibility(capability, support = detectLiteCapabilities()) {
90
+ const descriptor = descriptorByCapability.get(capability);
91
+ if (!descriptor)
92
+ throw new Error(`Unknown Lite capability: ${capability}`);
93
+ const supported = support[capability];
94
+ return { ...descriptor, supported, mode: supported ? 'enhanced' : 'fallback' };
95
+ }
@@ -2,66 +2,74 @@
2
2
  /**
3
3
  * LiteShowField — SSR-compatible field renderer for detail views.
4
4
  * Renders a single field value based on its type definition.
5
- */
6
- import type { FieldDefinition } from '@svadmin/core';
7
- import { toSafeHref, toSafeText } from '../security';
8
- import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
9
- import LiteMediaThumbnail from './LiteMediaThumbnail.svelte';
5
+ */
6
+ import type { FieldDefinition } from "@svadmin/core";
7
+ import { toSafeHref, toSafeText } from "../security";
8
+ import { isExplicitBooleanTrue, getStatusBadgeClass } from "../value-normalization";
9
+ import LiteMediaThumbnail from "./LiteMediaThumbnail.svelte";
10
10
 
11
11
  interface Props {
12
12
  field: FieldDefinition;
13
13
  value: unknown;
14
+ basePath?: string;
14
15
  }
15
16
 
16
- let { field, value }: Props = $props();
17
+ let { field, value, basePath = "/lite" }: Props = $props();
17
18
 
18
19
  function formatValue(v: unknown, f: FieldDefinition): string {
19
- if (v == null) return '';
20
- if (f.type === 'date') {
20
+ if (v == null) return "";
21
+ if (f.type === "date") {
21
22
  try { return new Date(v as string).toLocaleString(); } catch { return String(v); }
22
23
  }
23
- if (f.type === 'select' && f.options) {
24
+ if (f.type === "select" && f.options) {
24
25
  const opt = f.options.find(o => String(o.value) === String(v));
25
26
  return opt?.label ?? String(v);
26
27
  }
27
- if (f.type === 'url') return String(v);
28
- if (f.type === 'email') return String(v);
29
- if (Array.isArray(v)) return v.join(', ');
30
- if (typeof v === 'object') {
28
+ if (f.type === "relation" && typeof v === "object" && v !== null) {
29
+ return String((v as Record<string, unknown>)[f.optionLabel ?? "name"] ?? (v as Record<string, unknown>)[f.optionValue ?? "id"] ?? v);
30
+ }
31
+ if (f.type === "url") return String(v);
32
+ if (f.type === "email") return String(v);
33
+ if (Array.isArray(v)) return v.join(", ");
34
+ if (typeof v === "object") {
31
35
  try { return JSON.stringify(v, null, 2); } catch { return String(v); }
32
36
  }
33
37
  return String(v);
34
38
  }
35
39
  </script>
36
40
 
37
- {#if field.type === 'boolean'}
41
+ {#if field.type === "boolean"}
38
42
  {@const checked = isExplicitBooleanTrue(value)}
39
- <span class="lite-bool {checked ? 'lite-bool-true' : ''}"></span>
40
- {checked ? '✓ Yes' : '✗ No'}
41
- {:else if field.type === 'url' && value}
43
+ <span class="lite-bool {checked ? "lite-bool-true" : ""}"></span>
44
+ {checked ? "✓ Yes" : "✗ No"}
45
+ {:else if field.type === "url" && value}
42
46
  {@const href = toSafeHref(value)}
43
47
  {#if href}
44
48
  <a {href} target="_blank" rel="noopener noreferrer">{toSafeText(value)}</a>
45
49
  {:else}
46
50
  {toSafeText(value)}
47
51
  {/if}
48
- {:else if field.type === 'email' && value}
52
+ {:else if field.type === "email" && value}
49
53
  {@const href = toSafeHref(`mailto:${toSafeText(value)}`)}
50
54
  {#if href}
51
55
  <a {href}>{toSafeText(value)}</a>
52
56
  {:else}
53
57
  {toSafeText(value)}
54
58
  {/if}
55
- {:else if field.type === 'image' && value}
59
+ {:else if field.type === "image" && value}
56
60
  <LiteMediaThumbnail src={String(value)} alt={field.label} height={200} />
57
- {:else if field.type === 'tags' && Array.isArray(value)}
61
+ {:else if field.type === "tags" && Array.isArray(value)}
58
62
  {#each value as tag, _i (_i)}
59
63
  <span class="lite-badge">{tag}</span>
60
64
  {/each}
61
- {:else if field.type === 'json' && value}
62
- <pre style="margin:0;font-size:12px;background:#f8fafc;padding:12px;border-radius:6px;border:1px solid #e2e8f0;overflow-x:auto;">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>
65
+ {:else if field.type === "json" && value}
66
+ <pre style="margin:0;font-size:12px;background:#f8fafc;padding:12px;border-radius:6px;border:1px solid #e2e8f0;overflow-x:auto;">{typeof value === "string" ? value : JSON.stringify(value, null, 2)}</pre>
63
67
  {:else if field.type === "select" && field.options}
64
68
  <span class={getStatusBadgeClass(value)}>{formatValue(value, field)}</span>
69
+ {:else if field.type === "relation" && field.resource && value != null}
70
+ <a href={`${basePath}/${field.resource}/show/${value}`} class="lite-badge lite-badge-info">
71
+ {formatValue(value, field)} &rarr;
72
+ </a>
65
73
  {:else}
66
74
  {formatValue(value, field)}
67
75
  {/if}
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * LiteShowField — SSR-compatible field renderer for detail views.
3
3
  * Renders a single field value based on its type definition.
4
- */
5
- import type { FieldDefinition } from '@svadmin/core';
4
+ */
5
+ import type { FieldDefinition } from "@svadmin/core";
6
6
  interface Props {
7
7
  field: FieldDefinition;
8
8
  value: unknown;
9
+ basePath?: string;
9
10
  }
10
11
  declare const LiteShowField: import("svelte").Component<Props, {}, "">;
11
12
  type LiteShowField = ReturnType<typeof LiteShowField>;
@@ -3,77 +3,105 @@
3
3
  * LiteTable — Pure HTML table with <a> sort links.
4
4
  * No JavaScript required — sorting and pagination are URL-driven.
5
5
  */
6
- import type { ResourceDefinition, FieldDefinition } from '@svadmin/core';
7
- import { t } from '@svadmin/core/i18n';
8
- import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
9
- import { liteFragmentId } from '../fragment-id';
6
+ import type { ResourceDefinition, FieldDefinition } from "@svadmin/core";
7
+ import { t } from "@svadmin/core/i18n";
8
+ import { isExplicitBooleanTrue, getStatusBadgeClass } from "../value-normalization";
9
+ import { liteFragmentId } from "../fragment-id";
10
10
 
11
11
  interface Props {
12
12
  records: Record<string, unknown>[];
13
13
  resource: ResourceDefinition;
14
14
  currentSort?: string;
15
- currentOrder?: 'asc' | 'desc';
15
+ currentOrder?: "asc" | "desc";
16
16
  currentSearch?: string;
17
17
  basePath?: string;
18
18
  /** Show edit/delete action buttons */
19
19
  canShow?: boolean;
20
20
  canEdit?: boolean;
21
21
  canDelete?: boolean;
22
+ enableBatch?: boolean;
22
23
  }
23
24
 
24
25
  let {
25
26
  records,
26
27
  resource,
27
28
  currentSort,
28
- currentOrder = 'asc',
29
+ currentOrder = "asc",
29
30
  currentSearch,
30
- basePath = '/lite',
31
+ basePath = "/lite",
31
32
  canShow,
32
33
  canEdit,
33
34
  canDelete,
35
+ enableBatch = false,
34
36
  }: Props = $props();
35
37
 
36
38
  const tableId = $props.id();
37
39
 
38
- let pk = $derived(resource.primaryKey ?? 'id');
40
+ let pk = $derived(resource.primaryKey ?? "id");
39
41
  const showView = $derived(canShow ?? resource.canShow !== false);
40
42
  const showEdit = $derived(canEdit ?? resource.canEdit !== false);
41
43
  const showDelete = $derived(canDelete ?? resource.canDelete !== false);
44
+ const showBatch = $derived(enableBatch && showDelete);
42
45
  const listFields = $derived(
43
46
  resource.fields.filter(f => f.showInList !== false)
44
47
  );
45
48
 
46
49
  function sortUrl(field: FieldDefinition): string {
47
- const newOrder = currentSort === field.key && currentOrder === 'asc' ? 'desc' : 'asc';
50
+ const newOrder = currentSort === field.key && currentOrder === "asc" ? "desc" : "asc";
48
51
  const params = new URLSearchParams({ sort: field.key, order: newOrder });
49
- if (currentSearch) params.set('q', currentSearch);
50
- return `?${params.toString()}`;
52
+ if (currentSearch) params.set("q", currentSearch);
53
+ return "?" + params.toString();
51
54
  }
52
55
 
53
56
  function sortIndicator(field: FieldDefinition): string {
54
- if (currentSort !== field.key) return '';
55
- return currentOrder === 'asc' ? '' : '';
57
+ if (currentSort !== field.key) return "";
58
+ return currentOrder === "asc" ? "" : "";
59
+ }
60
+
61
+ function toggleAll(e: Event) {
62
+ const target = e.target as HTMLInputElement | null;
63
+ if (typeof document !== "undefined" && target) {
64
+ const checkboxes = document.querySelectorAll("input[name=ids]");
65
+ checkboxes.forEach((cb) => {
66
+ (cb as HTMLInputElement).checked = target.checked;
67
+ });
68
+ }
56
69
  }
57
70
 
58
71
  function formatValue(value: unknown, field: FieldDefinition): string {
59
- if (value == null) return '';
60
- if (field.type === 'boolean') return ''; // handled in template
61
- if (field.type === 'date') {
72
+ if (value == null) return "";
73
+ if (field.type === "boolean") return "";
74
+ if (field.type === "date") {
62
75
  try { return new Date(value as string).toLocaleDateString(); } catch { return String(value); }
63
76
  }
64
- if (field.type === 'select' && field.options) {
77
+ if (field.type === "select" && field.options) {
65
78
  const opt = field.options.find(o => String(o.value) === String(value));
66
79
  return opt?.label ?? String(value);
67
80
  }
68
- if (Array.isArray(value)) return value.join(', ');
81
+ if (field.type === "relation" && typeof value === "object" && value !== null) {
82
+ return String((value as Record<string, unknown>)[field.optionLabel ?? "name"] ?? (value as Record<string, unknown>)[field.optionValue ?? "id"] ?? value);
83
+ }
84
+ if (Array.isArray(value)) return value.join(", ");
69
85
  return String(value);
70
86
  }
71
-
72
87
  </script>
73
88
 
89
+ {#if showBatch}
90
+ <form id={"batch-delete-" + tableId} method="POST" action="?/batchDelete" style="display:none;"></form>
91
+ {/if}
92
+
74
93
  <table class="lite-table">
75
94
  <thead>
76
95
  <tr>
96
+ {#if showBatch}
97
+ <th style="width: 36px; text-align: center;">
98
+ <input
99
+ type="checkbox"
100
+ title="Select all"
101
+ onchange={toggleAll}
102
+ />
103
+ </th>
104
+ {/if}
77
105
  {#each listFields as field, _i (_i)}
78
106
  <th>
79
107
  {#if field.sortable !== false}
@@ -87,7 +115,7 @@
87
115
  </th>
88
116
  {/each}
89
117
  {#if showView || showEdit || showDelete}
90
- <th style="text-align:right;">{t('common.actions') || 'Actions'}</th>
118
+ <th style="text-align:right;">{t("common.actions") || "Actions"}</th>
91
119
  {/if}
92
120
  </tr>
93
121
  </thead>
@@ -95,16 +123,25 @@
95
123
  {#each records as record, _i (_i)}
96
124
  {@const id = record[pk]}
97
125
  <tr>
126
+ {#if showBatch}
127
+ <td style="text-align: center;">
128
+ <input type="checkbox" name="ids" value={String(id)} form={"batch-delete-" + tableId} />
129
+ </td>
130
+ {/if}
98
131
  {#each listFields as field, _i (_i)}
99
132
  <td>
100
- {#if field.type === 'boolean'}
101
- <span class="lite-bool {isExplicitBooleanTrue(record[field.key]) ? 'lite-bool-true' : ''}"></span>
102
- {:else if field.type === 'tags' && Array.isArray(record[field.key])}
133
+ {#if field.type === "boolean"}
134
+ <span class="lite-bool {isExplicitBooleanTrue(record[field.key]) ? "lite-bool-true" : ""}"></span>
135
+ {:else if field.type === "tags" && Array.isArray(record[field.key])}
103
136
  {#each (record[field.key] as string[]).slice(0, 3) as tag, _i (_i)}
104
137
  <span class="lite-badge">{tag}</span>
105
138
  {/each}
106
- {:else if field.type === 'select' && field.options}
139
+ {:else if field.type === "select" && field.options}
107
140
  <span class={getStatusBadgeClass(record[field.key])}>{formatValue(record[field.key], field)}</span>
141
+ {:else if field.type === "relation" && field.resource && record[field.key] != null}
142
+ <a href={basePath + "/" + field.resource + "/show/" + record[field.key]} class="lite-badge lite-badge-info">
143
+ {formatValue(record[field.key], field)}
144
+ </a>
108
145
  {:else}
109
146
  {formatValue(record[field.key], field)}
110
147
  {/if}
@@ -113,28 +150,28 @@
113
150
  {#if showView || showEdit || showDelete}
114
151
  <td class="actions">
115
152
  {#if showView}
116
- <a href={`${basePath}/${resource.name}/show/${id}`} class="lite-btn lite-btn-sm">{t('common.show') || 'Show'}</a>
153
+ <a href={basePath + "/" + resource.name + "/show/" + id} class="lite-btn lite-btn-sm">{t("common.show") || "Show"}</a>
117
154
  {/if}
118
155
  {#if showEdit}
119
- <a href={`${basePath}/${resource.name}/edit/${id}`} class="lite-btn lite-btn-sm">{t('common.edit') || 'Edit'}</a>
156
+ <a href={basePath + "/" + resource.name + "/edit/" + id} class="lite-btn lite-btn-sm">{t("common.edit") || "Edit"}</a>
120
157
  {/if}
121
158
  {#if showDelete}
122
- {@const confirmationId = liteFragmentId('delete', tableId, resource.name, String(id))}
123
- {@const confirmationTitleId = `${confirmationId}-title`}
159
+ {@const confirmationId = liteFragmentId("delete", tableId, resource.name, String(id))}
160
+ {@const confirmationTitleId = confirmationId + "-title"}
124
161
  <div class="lite-confirm">
125
- <span id={`${confirmationId}-closed`} class="lite-confirm-cancel-target" aria-hidden="true"></span>
162
+ <span id={confirmationId + "-closed"} class="lite-confirm-cancel-target" aria-hidden="true"></span>
126
163
  <a
127
- href={`#${confirmationId}`}
164
+ href={"#" + confirmationId}
128
165
  class="lite-btn lite-btn-sm lite-btn-danger"
129
166
  aria-controls={confirmationId}
130
167
  aria-haspopup="dialog"
131
- >{t('common.delete') || 'Delete'}</a>
168
+ >{t("common.delete") || "Delete"}</a>
132
169
  <div id={confirmationId} class="lite-confirm-panel lite-confirm-target" role="dialog" aria-labelledby={confirmationTitleId} tabindex="-1">
133
- <p id={confirmationTitleId} style="margin:0 0 8px;font-size:13px;">{t('common.areYouSure') || 'Are you sure?'}</p>
170
+ <p id={confirmationTitleId} style="margin:0 0 8px;font-size:13px;">{t("common.areYouSure") || "Are you sure?"}</p>
134
171
  <form method="POST" action="?/delete" class="lite-inline-actions">
135
172
  <input type="hidden" name="id" value={String(id)} />
136
- <a href={`#${confirmationId}-closed`} class="lite-btn lite-btn-sm">{t('common.cancel') || 'Cancel'}</a>
137
- <button type="submit" class="lite-btn lite-btn-sm lite-btn-danger">{t('common.confirm') || 'Confirm'}</button>
173
+ <a href={"#" + confirmationId + "-closed"} class="lite-btn lite-btn-sm">{t("common.cancel") || "Cancel"}</a>
174
+ <button type="submit" class="lite-btn lite-btn-sm lite-btn-danger">{t("common.confirm") || "Confirm"}</button>
138
175
  </form>
139
176
  </div>
140
177
  </div>
@@ -144,10 +181,26 @@
144
181
  </tr>
145
182
  {:else}
146
183
  <tr>
147
- <td colspan={listFields.length + (showView || showEdit || showDelete ? 1 : 0)} style="text-align:center;padding:40px;color:#9ca3af;">
148
- {t('common.noData') || 'No records found.'}
184
+ <td colspan={listFields.length + (showView || showEdit || showDelete ? 1 : 0) + (showBatch ? 1 : 0)} style="text-align:center;padding:40px;color:#9ca3af;">
185
+ {t("common.noData") || "No records found."}
149
186
  </td>
150
187
  </tr>
151
188
  {/each}
152
189
  </tbody>
153
190
  </table>
191
+
192
+ {#if showBatch && records.length > 0}
193
+ <div class="lite-batch-bar">
194
+ <span style="font-size: 13px; color: #64748b;">
195
+ Select items above to perform batch actions
196
+ </span>
197
+ <button
198
+ type="submit"
199
+ form={"batch-delete-" + tableId}
200
+ class="lite-btn lite-btn-sm lite-btn-danger"
201
+
202
+ >
203
+ {t("common.delete") || "Delete Selected"}
204
+ </button>
205
+ </div>
206
+ {/if}
@@ -2,18 +2,19 @@
2
2
  * LiteTable — Pure HTML table with <a> sort links.
3
3
  * No JavaScript required — sorting and pagination are URL-driven.
4
4
  */
5
- import type { ResourceDefinition } from '@svadmin/core';
5
+ import type { ResourceDefinition } from "@svadmin/core";
6
6
  interface Props {
7
7
  records: Record<string, unknown>[];
8
8
  resource: ResourceDefinition;
9
9
  currentSort?: string;
10
- currentOrder?: 'asc' | 'desc';
10
+ currentOrder?: "asc" | "desc";
11
11
  currentSearch?: string;
12
12
  basePath?: string;
13
13
  /** Show edit/delete action buttons */
14
14
  canShow?: boolean;
15
15
  canEdit?: boolean;
16
16
  canDelete?: boolean;
17
+ enableBatch?: boolean;
17
18
  }
18
19
  declare const LiteTable: import("svelte").Component<Props, {}, "">;
19
20
  type LiteTable = ReturnType<typeof LiteTable>;