yaxa-svelte 1.2.1 → 1.4.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 (59) hide show
  1. package/README.md +84 -11
  2. package/dist/admin/components/AdminDashboard.svelte +466 -0
  3. package/dist/admin/components/AdminDashboard.svelte.d.ts +13 -0
  4. package/dist/admin/components/DevSandbox.svelte +284 -0
  5. package/dist/admin/components/DevSandbox.svelte.d.ts +7 -0
  6. package/dist/admin/components/ImpersonationBanner.svelte +71 -0
  7. package/dist/admin/components/ImpersonationBanner.svelte.d.ts +13 -0
  8. package/dist/admin/components/RecordDrawer.svelte +222 -0
  9. package/dist/admin/components/RecordDrawer.svelte.d.ts +13 -0
  10. package/dist/admin/handler.d.ts +96 -0
  11. package/dist/admin/handler.js +340 -0
  12. package/dist/admin/hook.d.ts +19 -0
  13. package/dist/admin/hook.js +274 -0
  14. package/dist/admin/index.d.ts +7 -0
  15. package/dist/admin/index.js +9 -0
  16. package/dist/admin/types.d.ts +42 -0
  17. package/dist/admin/types.js +1 -0
  18. package/dist/components/elements/Icon.svelte +2 -1
  19. package/dist/components/elements/MetricCard.svelte +228 -0
  20. package/dist/components/elements/MetricCard.svelte.d.ts +19 -0
  21. package/dist/components/forms/DatePicker.svelte +322 -0
  22. package/dist/components/forms/DatePicker.svelte.d.ts +17 -0
  23. package/dist/components/forms/DateRangePicker.svelte +397 -0
  24. package/dist/components/forms/DateRangePicker.svelte.d.ts +27 -0
  25. package/dist/components/forms/InputOTP.svelte +165 -0
  26. package/dist/components/forms/InputOTP.svelte.d.ts +17 -0
  27. package/dist/components/forms/ToggleGroup.svelte +151 -0
  28. package/dist/components/forms/ToggleGroup.svelte.d.ts +22 -0
  29. package/dist/components/layout/Footer.svelte +33 -7
  30. package/dist/components/layout/Footer.svelte.d.ts +1 -1
  31. package/dist/components/layout/YaxaApp.svelte +10 -3
  32. package/dist/components/layout/YaxaApp.svelte.d.ts +2 -0
  33. package/dist/components/saas/Gate.svelte +170 -0
  34. package/dist/components/saas/Gate.svelte.d.ts +37 -0
  35. package/dist/components/seo/Seo.svelte +15 -2
  36. package/dist/components/seo/Seo.svelte.d.ts +1 -1
  37. package/dist/composables/useGate.svelte.d.ts +23 -0
  38. package/dist/composables/useGate.svelte.js +61 -0
  39. package/dist/composables/useUpload.svelte.d.ts +32 -0
  40. package/dist/composables/useUpload.svelte.js +117 -0
  41. package/dist/index.d.ts +19 -4
  42. package/dist/index.js +11 -3
  43. package/dist/server/db/index.js +18 -7
  44. package/dist/server/email/index.d.ts +34 -2
  45. package/dist/server/email/index.js +21 -0
  46. package/dist/server/index.d.ts +2 -0
  47. package/dist/server/index.js +2 -0
  48. package/dist/server/storage/index.d.ts +46 -0
  49. package/dist/server/storage/index.js +138 -0
  50. package/dist/site/config.d.ts +27 -1
  51. package/dist/site/config.js +60 -15
  52. package/dist/site/context.d.ts +19 -0
  53. package/dist/site/context.js +20 -0
  54. package/dist/site/og.d.ts +1 -1
  55. package/dist/site/og.js +7 -3
  56. package/dist/site/schema.d.ts +34 -1
  57. package/dist/site/schema.js +54 -2
  58. package/dist/styles/yaxa.css +70 -0
  59. package/package.json +23 -1
package/README.md CHANGED
@@ -68,7 +68,7 @@ export default defineConfig({
68
68
 
69
69
  ### 3. Define Your Site Configuration
70
70
 
71
- Create `src/site.config.ts` — your single source of truth for branding, metadata, and SEO:
71
+ Create `src/site.config.ts` — your single source of truth for branding, metadata, licensing, and SEO:
72
72
 
73
73
  ```ts
74
74
  // src/site.config.ts
@@ -79,14 +79,20 @@ export const siteConfig = defineSiteConfig({
79
79
  title: 'My Indie SaaS — Build Fast with SvelteKit',
80
80
  description: 'Fast, beautiful, accessible web applications.',
81
81
  url: 'https://my-app.com',
82
+ // Single-email mode: Solo devs can set one email for contact, support & DPO
83
+ email: 'hello@my-app.com',
84
+ project: {
85
+ license: 'MIT',
86
+ type: 'open-source',
87
+ repositoryUrl: 'https://github.com/my-org/my-app'
88
+ },
82
89
  theme: {
83
90
  accent: 'svelte', // 'svelte' | 'emerald' | 'amber' | 'sky' | 'violet' | 'rose' | 'indigo'
84
91
  neutral: 'zinc', // 'zinc' | 'slate' | 'stone' | 'neutral'
85
92
  defaultMode: 'dark'
86
93
  },
87
94
  socials: {
88
- github: 'https://github.com/my-org/my-app',
89
- twitter: '@my_app'
95
+ github: 'https://github.com/my-org/my-app'
90
96
  }
91
97
  });
92
98
  ```
@@ -138,6 +144,55 @@ export const handle = sequence(yaxaHook, authHook);
138
144
 
139
145
  ---
140
146
 
147
+ ## 🛡️ Declarative Feature Gating (`<Gate />` & `useGate()`)
148
+
149
+ Declarative and programmatic subscription tier and RBAC gating with automatic Svelte 5 context fallback:
150
+
151
+ ```svelte
152
+ <script lang="ts">
153
+ import { Gate, useGate } from 'yaxa-svelte';
154
+
155
+ const gate = useGate();
156
+ </script>
157
+
158
+ <!-- 0 props needed when wrapped in <YaxaApp user={data.user}> -->
159
+ <Gate requiredPlan="pro" preview={true}>
160
+ <AdvancedAnalyticsWidget />
161
+ </Gate>
162
+ ```
163
+
164
+ ---
165
+
166
+ ## 🗄️ Drizzle Admin & Introspection Suite (`yaxa-svelte/admin`)
167
+
168
+ Dual-mode Drizzle ORM administration and introspection suite with live CRUD table editor, developer sandbox, and user impersonation:
169
+
170
+ ```ts
171
+ // Approach A (Zero-File Route Interceptor in src/hooks.server.ts)
172
+ import { sequence } from '@sveltejs/kit/hooks';
173
+ import { createYaxaAdminHook } from 'yaxa-svelte/admin';
174
+ import { db } from '$lib/server/db';
175
+ import * as schema from '$lib/server/db/schema-pg';
176
+
177
+ export const handle = sequence(createYaxaAdminHook({ db, schema, path: '/admin' }));
178
+ ```
179
+
180
+ ---
181
+
182
+ ## ☁️ Cloud Storage (`useUpload()` & Presigned S3/R2 Uploads)
183
+
184
+ ```svelte
185
+ <!-- Client Upload with Progress Tracking -->
186
+ <script lang="ts">
187
+ import { useUpload } from 'yaxa-svelte';
188
+ const uploader = useUpload({ endpoint: '/api/upload' });
189
+ </script>
190
+
191
+ <input type="file" onchange={(e) => uploader.upload(e.currentTarget.files[0])} />
192
+ ```
193
+
194
+ ---
195
+
141
196
  ## 💳 Polar.sh Payments & Webhook Synchronization
142
197
 
143
198
  Create a webhook endpoint in `src/routes/api/webhooks/polar/+server.ts`:
@@ -151,16 +206,34 @@ export const POST = createPolarWebhookHandler();
151
206
 
152
207
  ---
153
208
 
209
+ ## 📦 Subpath Exports Architecture
210
+
211
+ | Subpath | Description |
212
+ | :--------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
213
+ | `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useAuth`, `useClipboard`, etc.) |
214
+ | `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
215
+ | `yaxa-svelte/server` | Universal Server Hooks, SEO endpoints, Drizzle DB drivers, Cloud Storage handlers, and Email dispatch |
216
+ | `yaxa-svelte/auth` | Better-Auth integration and protected route server guards (`createYaxaAuth`, `createYaxaAuthHook`) |
217
+ | `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
218
+ | `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
219
+ | `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
220
+ | `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
221
+ | `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
222
+ | `yaxa-svelte/yaxa.css` | Tailwind CSS v4 design tokens and base stylesheet |
223
+
224
+ ---
225
+
154
226
  ## 🧩 Component Library
155
227
 
156
- | Category | Components |
157
- | :----------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
- | **SaaS Suite** | `AuthCard`, `UserMenu`, `PricingCard`, `PricingTable`, `SubscriptionCard`, `useAuth` |
159
- | **Elements** | `Button`, `ButtonGroup`, `Badge`, `Avatar`, `AvatarGroup`, `DataTable`, `Chip`, `Meter`, `Kbd`, `Icon`, `Spinner`, `Progress`, `Skeleton`, `Link`, `Logo` |
160
- | **Forms** | `Form`, `FormField`, `Input`, `Textarea`, `Checkbox`, `Switch`, `Select`, `RadioGroup`, `Slider`, `ColorPicker` |
161
- | **Layout** | `Container`, `Header`, `Footer`, `Section`, `Card`, `Divider`, `YaxaApp` |
162
- | **Overlays & Nav** | `Tabs`, `Breadcrumb`, `Pagination`, `CommandPalette` (`⌘K`), `DropdownMenu`, `ContextMenu`, `Modal`, `Slideover`, `Popover`, `Tooltip`, `Alert`, `Accordion` |
163
- | **Composables** | `useAuth`, `useClipboard`, `useShortcuts`, `useColorMode`, `useToast`, `useMediaQuery`, `useDebounce` |
228
+ | Category | Components |
229
+ | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
230
+ | **SaaS Suite** | `<Gate>`, `<AuthCard>`, `<UserMenu>`, `<PricingCard>`, `<PricingTable>`, `<SubscriptionCard>`, `useAuth`, `useGate`, `useUpload` |
231
+ | **Admin Suite** | `<AdminDashboard>`, `<RecordDrawer>`, `<DevSandbox>`, `<ImpersonationBanner>`, `createYaxaAdminHook`, `createDrizzleAdmin` |
232
+ | **Elements** | `Button`, `ButtonGroup`, `Badge`, `Avatar`, `AvatarGroup`, `DataTable`, `Chip`, `Meter`, `MetricCard`, `Kbd`, `Icon`, `Spinner`, `Progress`, `Skeleton`, `Link`, `Logo` |
233
+ | **Forms** | `Form`, `FormField`, `Input`, `InputOTP`, `Textarea`, `Checkbox`, `Switch`, `Select`, `RadioGroup`, `Slider`, `ColorPicker`, `ToggleGroup`, `DatePicker`, `DateRangePicker` |
234
+ | **Layout & Blocks** | `Container`, `Header`, `Footer`, `Section`, `Card`, `Divider`, `YaxaApp`, Page Blocks (Hero, SaaS Metrics, 2FA Security) |
235
+ | **Overlays & Nav** | `Tabs`, `Breadcrumb`, `Pagination`, `CommandPalette` (`⌘K`), `DropdownMenu`, `ContextMenu`, `Modal`, `Slideover`, `Popover`, `Tooltip`, `Alert`, `Accordion` |
236
+ | **Composables** | `useAuth`, `useGate`, `useUpload`, `useClipboard`, `useShortcuts`, `useColorMode`, `useToast`, `useMediaQuery`, `useDebounce` |
164
237
 
165
238
  ---
166
239
 
@@ -0,0 +1,466 @@
1
+ <script lang="ts">
2
+ import Button from '../../components/elements/Button.svelte';
3
+ import Badge from '../../components/elements/Badge.svelte';
4
+ import Icon from '../../components/elements/Icon.svelte';
5
+ import Modal from '../../components/overlays/Modal.svelte';
6
+ import Pagination from '../../components/navigation/Pagination.svelte';
7
+ import RecordDrawer from './RecordDrawer.svelte';
8
+ import DevSandbox from './DevSandbox.svelte';
9
+ import type { AdminTableData } from '../types';
10
+
11
+ interface Props {
12
+ data: AdminTableData;
13
+ title?: string;
14
+ apiPrefix?: string;
15
+ oncreate?: (data: Record<string, any>) => Promise<void> | void;
16
+ onupdate?: (pk: string | number, data: Record<string, any>) => Promise<void> | void;
17
+ ondelete?: (pk: string | number) => Promise<void> | void;
18
+ onrefresh?: () => Promise<void> | void;
19
+ }
20
+
21
+ let {
22
+ data,
23
+ title = 'Yaxa Admin Introspection Suite',
24
+ apiPrefix = '/admin/api',
25
+ oncreate,
26
+ onupdate,
27
+ ondelete,
28
+ onrefresh
29
+ }: Props = $props();
30
+
31
+ let activeTab = $state<'tables' | 'sandbox'>('tables');
32
+ let currentTable = $derived(data.currentTable || data.tables[0] || '');
33
+ let searchQuery = $state('');
34
+ let selectedRecord = $state<Record<string, any> | null>(null);
35
+ let isDrawerOpen = $state(false);
36
+ let isCreateModalOpen = $state(false);
37
+ let isCreating = $state(false);
38
+ let newRecordData = $state<Record<string, any>>({});
39
+ let currentPage = $state(1);
40
+
41
+ $effect(() => {
42
+ currentPage = data.page || 1;
43
+ searchQuery = data.search || '';
44
+ });
45
+
46
+ $effect(() => {
47
+ if (currentPage !== data.page && typeof window !== 'undefined') {
48
+ const url = new URL(window.location.href);
49
+ url.searchParams.set('page', String(currentPage));
50
+ window.location.href = url.toString();
51
+ }
52
+ });
53
+
54
+ function inspectRecord(record: Record<string, any>) {
55
+ selectedRecord = record;
56
+ isDrawerOpen = true;
57
+ }
58
+
59
+ function switchTable(tableName: string) {
60
+ if (typeof window !== 'undefined') {
61
+ const url = new URL(window.location.href);
62
+ url.searchParams.set('table', tableName);
63
+ url.searchParams.set('page', '1');
64
+ window.location.href = url.toString();
65
+ }
66
+ }
67
+
68
+ function handleSearch(e: Event) {
69
+ e.preventDefault();
70
+ if (typeof window !== 'undefined') {
71
+ const url = new URL(window.location.href);
72
+ url.searchParams.set('search', searchQuery);
73
+ url.searchParams.set('page', '1');
74
+ window.location.href = url.toString();
75
+ }
76
+ }
77
+
78
+ async function handleSaveEditedRecord(updatedData: Record<string, any>) {
79
+ if (onupdate && selectedRecord) {
80
+ await onupdate(selectedRecord[data.primaryKey], updatedData);
81
+ return;
82
+ }
83
+
84
+ // Fallback to API endpoint
85
+ const res = await fetch(`${apiPrefix}/record`, {
86
+ method: 'PUT',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({
89
+ table: currentTable,
90
+ pk: selectedRecord?.[data.primaryKey],
91
+ data: updatedData
92
+ })
93
+ });
94
+
95
+ if (res.ok) {
96
+ if (onrefresh) await onrefresh();
97
+ else if (typeof window !== 'undefined') window.location.reload();
98
+ } else {
99
+ const err = await res.json().catch(() => ({}));
100
+ alert(`Update error: ${err.error || 'Failed to save changes'}`);
101
+ }
102
+ }
103
+
104
+ async function handleDeleteRecord(pk: string | number) {
105
+ if (ondelete) {
106
+ await ondelete(pk);
107
+ return;
108
+ }
109
+
110
+ // Fallback to API endpoint
111
+ const res = await fetch(`${apiPrefix}/record`, {
112
+ method: 'DELETE',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({
115
+ table: currentTable,
116
+ pk
117
+ })
118
+ });
119
+
120
+ if (res.ok) {
121
+ if (onrefresh) await onrefresh();
122
+ else if (typeof window !== 'undefined') window.location.reload();
123
+ } else {
124
+ const err = await res.json().catch(() => ({}));
125
+ alert(`Delete error: ${err.error || 'Failed to delete'}`);
126
+ }
127
+ }
128
+
129
+ async function handleCreateRecord(e: Event) {
130
+ e.preventDefault();
131
+ isCreating = true;
132
+ try {
133
+ if (oncreate) {
134
+ await oncreate(newRecordData);
135
+ isCreateModalOpen = false;
136
+ newRecordData = {};
137
+ return;
138
+ }
139
+
140
+ // Fallback to API endpoint
141
+ const res = await fetch(`${apiPrefix}/record`, {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({
145
+ table: currentTable,
146
+ data: newRecordData
147
+ })
148
+ });
149
+
150
+ if (res.ok) {
151
+ isCreateModalOpen = false;
152
+ newRecordData = {};
153
+ if (onrefresh) await onrefresh();
154
+ else if (typeof window !== 'undefined') window.location.reload();
155
+ } else {
156
+ const err = await res.json().catch(() => ({}));
157
+ alert(`Create error: ${err.error || 'Failed to create record'}`);
158
+ }
159
+ } finally {
160
+ isCreating = false;
161
+ }
162
+ }
163
+ </script>
164
+
165
+ <div
166
+ class="min-h-screen bg-neutral-50 text-neutral-900 transition-colors dark:bg-neutral-950 dark:text-neutral-50"
167
+ >
168
+ <!-- Top Navigation Bar -->
169
+ <header
170
+ class="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-neutral-200 bg-white/80 px-6 backdrop-blur-md dark:border-neutral-800 dark:bg-neutral-900/80"
171
+ >
172
+ <div class="flex items-center gap-3">
173
+ <div
174
+ class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-600 text-xs font-bold text-white shadow-xs"
175
+ >
176
+ Y
177
+ </div>
178
+ <div class="flex items-center gap-2">
179
+ <h1 class="text-sm font-bold text-neutral-900 dark:text-neutral-100">{title}</h1>
180
+ <Badge color="primary" variant="subtle" size="xs">Drizzle Introspect</Badge>
181
+ </div>
182
+ </div>
183
+
184
+ <!-- Nav Tabs & Actions -->
185
+ <div class="flex items-center gap-3">
186
+ <div class="flex items-center rounded-lg bg-neutral-100 p-0.5 dark:bg-neutral-800">
187
+ <button
188
+ type="button"
189
+ class="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors {activeTab ===
190
+ 'tables'
191
+ ? 'bg-white text-neutral-900 shadow-xs dark:bg-neutral-700 dark:text-white'
192
+ : 'text-neutral-600 hover:text-neutral-900 dark:text-neutral-400'}"
193
+ onclick={() => (activeTab = 'tables')}
194
+ >
195
+ <Icon name="search" class="h-3 w-3" />
196
+ <span>Database Tables</span>
197
+ </button>
198
+
199
+ <button
200
+ type="button"
201
+ class="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors {activeTab ===
202
+ 'sandbox'
203
+ ? 'bg-white text-neutral-900 shadow-xs dark:bg-neutral-700 dark:text-white'
204
+ : 'text-neutral-600 hover:text-neutral-900 dark:text-neutral-400'}"
205
+ onclick={() => (activeTab = 'sandbox')}
206
+ >
207
+ <Icon name="sparkles" class="h-3 w-3" />
208
+ <span>Dev Sandbox</span>
209
+ </button>
210
+ </div>
211
+
212
+ {#if data.studioUrl}
213
+ <a
214
+ href={data.studioUrl}
215
+ target="_blank"
216
+ rel="noopener noreferrer"
217
+ class="inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 shadow-xs transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700"
218
+ >
219
+ <Icon name="external-link" class="h-3.5 w-3.5" />
220
+ <span>Drizzle Studio</span>
221
+ </a>
222
+ {/if}
223
+ </div>
224
+ </header>
225
+
226
+ <!-- Main Workspace -->
227
+ <main class="mx-auto max-w-7xl p-6">
228
+ {#if activeTab === 'sandbox'}
229
+ <div class="space-y-6">
230
+ <div class="space-y-1">
231
+ <h2 class="text-lg font-bold text-neutral-900 dark:text-neutral-100">
232
+ Developer Sandbox & Introspection
233
+ </h2>
234
+ <p class="text-xs text-neutral-500">
235
+ Live testing suite for transactional email templates and S3/R2 direct uploads.
236
+ </p>
237
+ </div>
238
+ <DevSandbox {apiPrefix} />
239
+ </div>
240
+ {:else}
241
+ <div class="grid grid-cols-1 gap-6 md:grid-cols-12">
242
+ <!-- Left Sidebar: Tables List -->
243
+ <aside class="space-y-4 md:col-span-3">
244
+ <div class="flex items-center justify-between">
245
+ <span class="text-xs font-bold tracking-wider text-neutral-400 uppercase"
246
+ >Schemas & Models</span
247
+ >
248
+ <Badge size="xs" variant="outline" color="neutral">{data.tables.length} Tables</Badge>
249
+ </div>
250
+
251
+ <div class="space-y-1">
252
+ {#each data.tables as table}
253
+ {@const isActive = table === currentTable}
254
+ <button
255
+ type="button"
256
+ class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-medium transition-colors {isActive
257
+ ? 'bg-primary-50 font-semibold text-primary-700 dark:bg-primary-950/50 dark:text-primary-300'
258
+ : 'text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-100'}"
259
+ onclick={() => switchTable(table)}
260
+ >
261
+ <div class="flex items-center gap-2 truncate">
262
+ <Icon name="copy" class="h-3.5 w-3.5 shrink-0 opacity-60" />
263
+ <span class="truncate">{table}</span>
264
+ </div>
265
+ {#if isActive}
266
+ <span class="h-1.5 w-1.5 rounded-full bg-primary-600"></span>
267
+ {/if}
268
+ </button>
269
+ {/each}
270
+ </div>
271
+ </aside>
272
+
273
+ <!-- Right Workspace: Table Data View -->
274
+ <section class="space-y-4 md:col-span-9">
275
+ <!-- Table Toolbar -->
276
+ <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
277
+ <div class="flex items-center gap-3">
278
+ <h2 class="text-lg font-bold text-neutral-900 dark:text-neutral-100">
279
+ {currentTable}
280
+ </h2>
281
+ <Badge size="xs" variant="subtle" color="neutral">{data.total} rows</Badge>
282
+ </div>
283
+
284
+ <div class="flex items-center gap-2">
285
+ <form onsubmit={handleSearch} class="relative">
286
+ <input
287
+ type="search"
288
+ placeholder="Search records..."
289
+ bind:value={searchQuery}
290
+ class="w-48 rounded-lg border border-neutral-300 bg-white py-1.5 pr-3 pl-8 text-xs text-neutral-900 placeholder-neutral-400 transition-all focus:w-60 focus:border-primary-500 focus:ring-1 focus:ring-primary-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
291
+ />
292
+ <div
293
+ class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2.5 text-neutral-400"
294
+ >
295
+ <Icon name="search" class="h-3.5 w-3.5" />
296
+ </div>
297
+ </form>
298
+
299
+ <Button
300
+ variant="solid"
301
+ color="primary"
302
+ size="sm"
303
+ class="gap-1 text-xs"
304
+ onclick={() => (isCreateModalOpen = true)}
305
+ >
306
+ <Icon name="sparkles" class="h-3.5 w-3.5" />
307
+ <span>New Record</span>
308
+ </Button>
309
+ </div>
310
+ </div>
311
+
312
+ <!-- Table Records Container -->
313
+ <div
314
+ class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-xs dark:border-neutral-800 dark:bg-neutral-950"
315
+ >
316
+ <div class="overflow-x-auto">
317
+ <table class="w-full text-left text-xs text-neutral-600 dark:text-neutral-300">
318
+ <thead
319
+ class="border-b border-neutral-200 bg-neutral-50/80 font-semibold tracking-wider text-neutral-500 uppercase dark:border-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-400"
320
+ >
321
+ <tr>
322
+ {#each data.columns as col}
323
+ <th class="px-4 py-3 whitespace-nowrap">
324
+ <div class="flex items-center gap-1.5">
325
+ <span>{col.name}</span>
326
+ {#if col.primaryKey}
327
+ <Badge size="xs" variant="solid" color="primary">PK</Badge>
328
+ {/if}
329
+ </div>
330
+ </th>
331
+ {/each}
332
+ <th class="px-4 py-3 text-right">Actions</th>
333
+ </tr>
334
+ </thead>
335
+
336
+ <tbody class="divide-y divide-neutral-200/80 dark:divide-neutral-800/80">
337
+ {#if data.records.length === 0}
338
+ <tr>
339
+ <td
340
+ colspan={data.columns.length + 1}
341
+ class="p-12 text-center text-xs text-neutral-500"
342
+ >
343
+ No records found in table <strong>{currentTable}</strong>.
344
+ </td>
345
+ </tr>
346
+ {:else}
347
+ {#each data.records as row}
348
+ <tr
349
+ class="cursor-pointer transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900/60"
350
+ onclick={() => inspectRecord(row)}
351
+ >
352
+ {#each data.columns as col}
353
+ <td class="max-w-[240px] truncate px-4 py-3 font-mono">
354
+ {row[col.name] !== undefined && row[col.name] !== null
355
+ ? String(row[col.name])
356
+ : '—'}
357
+ </td>
358
+ {/each}
359
+ <td class="px-4 py-3 text-right" onclick={(e) => e.stopPropagation()}>
360
+ <button
361
+ type="button"
362
+ class="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
363
+ onclick={() => inspectRecord(row)}
364
+ aria-label="Inspect row"
365
+ >
366
+ <Icon name="chevron-right" class="h-3.5 w-3.5" />
367
+ </button>
368
+ </td>
369
+ </tr>
370
+ {/each}
371
+ {/if}
372
+ </tbody>
373
+ </table>
374
+ </div>
375
+
376
+ <!-- Pagination Footer -->
377
+ {#if data.total > data.pageSize}
378
+ <div
379
+ class="flex items-center justify-between border-t border-neutral-200 px-4 py-3 dark:border-neutral-800"
380
+ >
381
+ <span class="text-xs text-neutral-500">
382
+ Showing {(data.page - 1) * data.pageSize + 1} to {Math.min(
383
+ data.page * data.pageSize,
384
+ data.total
385
+ )} of {data.total} records
386
+ </span>
387
+
388
+ <Pagination bind:page={currentPage} total={data.total} pageSize={data.pageSize} />
389
+ </div>
390
+ {/if}
391
+ </div>
392
+ </section>
393
+ </div>
394
+ {/if}
395
+ </main>
396
+
397
+ <!-- Slideover Inspector Drawer -->
398
+ <RecordDrawer
399
+ bind:open={isDrawerOpen}
400
+ record={selectedRecord}
401
+ columns={data.columns}
402
+ primaryKey={data.primaryKey}
403
+ tableName={currentTable}
404
+ onsave={handleSaveEditedRecord}
405
+ ondelete={handleDeleteRecord}
406
+ />
407
+
408
+ <!-- Create Record Modal -->
409
+ <Modal bind:open={isCreateModalOpen} title={`Create New Record in ${currentTable}`}>
410
+ <form onsubmit={handleCreateRecord} class="space-y-4 p-4 text-xs">
411
+ <div class="max-h-[420px] space-y-3 overflow-y-auto pr-1">
412
+ {#each data.columns as col}
413
+ <div class="space-y-1">
414
+ <div class="flex items-center justify-between">
415
+ <label
416
+ for={`create-${col.name}`}
417
+ class="font-medium text-neutral-700 dark:text-neutral-300"
418
+ >
419
+ {col.name}
420
+ </label>
421
+ <span class="text-2xs font-mono text-neutral-400">{col.dataType}</span>
422
+ </div>
423
+
424
+ {#if col.dataType === 'boolean'}
425
+ <input
426
+ id={`create-${col.name}`}
427
+ type="checkbox"
428
+ class="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
429
+ checked={Boolean(newRecordData[col.name])}
430
+ onchange={(e) => (newRecordData[col.name] = e.currentTarget.checked)}
431
+ />
432
+ {:else if col.dataType === 'number' || col.dataType === 'integer'}
433
+ <input
434
+ id={`create-${col.name}`}
435
+ type="number"
436
+ placeholder={col.primaryKey ? '(Auto-generated if empty)' : ''}
437
+ class="w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900"
438
+ oninput={(e) =>
439
+ (newRecordData[col.name] = e.currentTarget.value
440
+ ? Number(e.currentTarget.value)
441
+ : null)}
442
+ />
443
+ {:else}
444
+ <input
445
+ id={`create-${col.name}`}
446
+ type="text"
447
+ placeholder={col.primaryKey ? '(Auto-generated UUID if empty)' : ''}
448
+ class="w-full rounded-md border border-neutral-300 bg-white px-3 py-1.5 text-xs dark:border-neutral-700 dark:bg-neutral-900"
449
+ oninput={(e) => (newRecordData[col.name] = e.currentTarget.value)}
450
+ />
451
+ {/if}
452
+ </div>
453
+ {/each}
454
+ </div>
455
+
456
+ <div class="flex justify-end gap-2 border-t border-neutral-200 pt-3 dark:border-neutral-800">
457
+ <Button variant="outline" size="sm" onclick={() => (isCreateModalOpen = false)}>
458
+ Cancel
459
+ </Button>
460
+ <Button variant="solid" color="primary" size="sm" loading={isCreating} type="submit">
461
+ Insert Record
462
+ </Button>
463
+ </div>
464
+ </form>
465
+ </Modal>
466
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { AdminTableData } from '../types';
2
+ interface Props {
3
+ data: AdminTableData;
4
+ title?: string;
5
+ apiPrefix?: string;
6
+ oncreate?: (data: Record<string, any>) => Promise<void> | void;
7
+ onupdate?: (pk: string | number, data: Record<string, any>) => Promise<void> | void;
8
+ ondelete?: (pk: string | number) => Promise<void> | void;
9
+ onrefresh?: () => Promise<void> | void;
10
+ }
11
+ declare const AdminDashboard: import("svelte").Component<Props, {}, "">;
12
+ type AdminDashboard = ReturnType<typeof AdminDashboard>;
13
+ export default AdminDashboard;