yaxa-svelte 1.2.1 → 1.3.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 +69 -2
- package/dist/admin/components/AdminDashboard.svelte +466 -0
- package/dist/admin/components/AdminDashboard.svelte.d.ts +13 -0
- package/dist/admin/components/DevSandbox.svelte +284 -0
- package/dist/admin/components/DevSandbox.svelte.d.ts +7 -0
- package/dist/admin/components/ImpersonationBanner.svelte +71 -0
- package/dist/admin/components/ImpersonationBanner.svelte.d.ts +13 -0
- package/dist/admin/components/RecordDrawer.svelte +222 -0
- package/dist/admin/components/RecordDrawer.svelte.d.ts +13 -0
- package/dist/admin/handler.d.ts +96 -0
- package/dist/admin/handler.js +340 -0
- package/dist/admin/hook.d.ts +19 -0
- package/dist/admin/hook.js +274 -0
- package/dist/admin/index.d.ts +7 -0
- package/dist/admin/index.js +9 -0
- package/dist/admin/types.d.ts +42 -0
- package/dist/admin/types.js +1 -0
- package/dist/components/elements/Icon.svelte +2 -1
- package/dist/components/layout/YaxaApp.svelte +10 -3
- package/dist/components/layout/YaxaApp.svelte.d.ts +2 -0
- package/dist/components/saas/Gate.svelte +170 -0
- package/dist/components/saas/Gate.svelte.d.ts +37 -0
- package/dist/composables/useGate.svelte.d.ts +23 -0
- package/dist/composables/useGate.svelte.js +61 -0
- package/dist/composables/useUpload.svelte.d.ts +32 -0
- package/dist/composables/useUpload.svelte.js +117 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +4 -1
- package/dist/server/db/index.js +18 -7
- package/dist/server/email/index.d.ts +34 -2
- package/dist/server/email/index.js +21 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/storage/index.d.ts +46 -0
- package/dist/server/storage/index.js +138 -0
- package/dist/site/context.d.ts +19 -0
- package/dist/site/context.js +20 -0
- package/package.json +23 -1
package/README.md
CHANGED
|
@@ -138,6 +138,55 @@ export const handle = sequence(yaxaHook, authHook);
|
|
|
138
138
|
|
|
139
139
|
---
|
|
140
140
|
|
|
141
|
+
## 🛡️ Declarative Feature Gating (`<Gate />` & `useGate()`)
|
|
142
|
+
|
|
143
|
+
Declarative and programmatic subscription tier and RBAC gating with automatic Svelte 5 context fallback:
|
|
144
|
+
|
|
145
|
+
```svelte
|
|
146
|
+
<script lang="ts">
|
|
147
|
+
import { Gate, useGate } from 'yaxa-svelte';
|
|
148
|
+
|
|
149
|
+
const gate = useGate();
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<!-- 0 props needed when wrapped in <YaxaApp user={data.user}> -->
|
|
153
|
+
<Gate requiredPlan="pro" preview={true}>
|
|
154
|
+
<AdvancedAnalyticsWidget />
|
|
155
|
+
</Gate>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 🗄️ Drizzle Admin & Introspection Suite (`yaxa-svelte/admin`)
|
|
161
|
+
|
|
162
|
+
Dual-mode Drizzle ORM administration and introspection suite with live CRUD table editor, developer sandbox, and user impersonation:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// Approach A (Zero-File Route Interceptor in src/hooks.server.ts)
|
|
166
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
167
|
+
import { createYaxaAdminHook } from 'yaxa-svelte/admin';
|
|
168
|
+
import { db } from '$lib/server/db';
|
|
169
|
+
import * as schema from '$lib/server/db/schema-pg';
|
|
170
|
+
|
|
171
|
+
export const handle = sequence(createYaxaAdminHook({ db, schema, path: '/admin' }));
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## ☁️ Cloud Storage (`useUpload()` & Presigned S3/R2 Uploads)
|
|
177
|
+
|
|
178
|
+
```svelte
|
|
179
|
+
<!-- Client Upload with Progress Tracking -->
|
|
180
|
+
<script lang="ts">
|
|
181
|
+
import { useUpload } from 'yaxa-svelte';
|
|
182
|
+
const uploader = useUpload({ endpoint: '/api/upload' });
|
|
183
|
+
</script>
|
|
184
|
+
|
|
185
|
+
<input type="file" onchange={(e) => uploader.upload(e.currentTarget.files[0])} />
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
141
190
|
## 💳 Polar.sh Payments & Webhook Synchronization
|
|
142
191
|
|
|
143
192
|
Create a webhook endpoint in `src/routes/api/webhooks/polar/+server.ts`:
|
|
@@ -151,16 +200,34 @@ export const POST = createPolarWebhookHandler();
|
|
|
151
200
|
|
|
152
201
|
---
|
|
153
202
|
|
|
203
|
+
## 📦 Subpath Exports Architecture
|
|
204
|
+
|
|
205
|
+
| Subpath | Description |
|
|
206
|
+
| :--------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
207
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useAuth`, `useClipboard`, etc.) |
|
|
208
|
+
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
209
|
+
| `yaxa-svelte/server` | Universal Server Hooks, SEO endpoints, Drizzle DB drivers, Cloud Storage handlers, and Email dispatch |
|
|
210
|
+
| `yaxa-svelte/auth` | Better-Auth integration and protected route server guards (`createYaxaAuth`, `createYaxaAuthHook`) |
|
|
211
|
+
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
212
|
+
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
213
|
+
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
|
|
214
|
+
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
|
|
215
|
+
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
|
|
216
|
+
| `yaxa-svelte/yaxa.css` | Tailwind CSS v4 design tokens and base stylesheet |
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
154
220
|
## 🧩 Component Library
|
|
155
221
|
|
|
156
222
|
| Category | Components |
|
|
157
223
|
| :----------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
158
|
-
| **SaaS Suite** |
|
|
224
|
+
| **SaaS Suite** | `<Gate>`, `<AuthCard>`, `<UserMenu>`, `<PricingCard>`, `<PricingTable>`, `<SubscriptionCard>`, `useAuth`, `useGate`, `useUpload` |
|
|
225
|
+
| **Admin Suite** | `<AdminDashboard>`, `<RecordDrawer>`, `<DevSandbox>`, `<ImpersonationBanner>`, `createYaxaAdminHook`, `createDrizzleAdmin` |
|
|
159
226
|
| **Elements** | `Button`, `ButtonGroup`, `Badge`, `Avatar`, `AvatarGroup`, `DataTable`, `Chip`, `Meter`, `Kbd`, `Icon`, `Spinner`, `Progress`, `Skeleton`, `Link`, `Logo` |
|
|
160
227
|
| **Forms** | `Form`, `FormField`, `Input`, `Textarea`, `Checkbox`, `Switch`, `Select`, `RadioGroup`, `Slider`, `ColorPicker` |
|
|
161
228
|
| **Layout** | `Container`, `Header`, `Footer`, `Section`, `Card`, `Divider`, `YaxaApp` |
|
|
162
229
|
| **Overlays & Nav** | `Tabs`, `Breadcrumb`, `Pagination`, `CommandPalette` (`⌘K`), `DropdownMenu`, `ContextMenu`, `Modal`, `Slideover`, `Popover`, `Tooltip`, `Alert`, `Accordion` |
|
|
163
|
-
| **Composables** | `useAuth`, `useClipboard`, `useShortcuts`, `useColorMode`, `useToast`, `useMediaQuery`, `useDebounce`
|
|
230
|
+
| **Composables** | `useAuth`, `useGate`, `useUpload`, `useClipboard`, `useShortcuts`, `useColorMode`, `useToast`, `useMediaQuery`, `useDebounce` |
|
|
164
231
|
|
|
165
232
|
---
|
|
166
233
|
|
|
@@ -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;
|