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.
- package/README.md +84 -11
- 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/elements/MetricCard.svelte +228 -0
- package/dist/components/elements/MetricCard.svelte.d.ts +19 -0
- package/dist/components/forms/DatePicker.svelte +322 -0
- package/dist/components/forms/DatePicker.svelte.d.ts +17 -0
- package/dist/components/forms/DateRangePicker.svelte +397 -0
- package/dist/components/forms/DateRangePicker.svelte.d.ts +27 -0
- package/dist/components/forms/InputOTP.svelte +165 -0
- package/dist/components/forms/InputOTP.svelte.d.ts +17 -0
- package/dist/components/forms/ToggleGroup.svelte +151 -0
- package/dist/components/forms/ToggleGroup.svelte.d.ts +22 -0
- package/dist/components/layout/Footer.svelte +33 -7
- package/dist/components/layout/Footer.svelte.d.ts +1 -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/components/seo/Seo.svelte +15 -2
- package/dist/components/seo/Seo.svelte.d.ts +1 -1
- 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 +19 -4
- package/dist/index.js +11 -3
- 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/config.d.ts +27 -1
- package/dist/site/config.js +60 -15
- package/dist/site/context.d.ts +19 -0
- package/dist/site/context.js +20 -0
- package/dist/site/og.d.ts +1 -1
- package/dist/site/og.js +7 -3
- package/dist/site/schema.d.ts +34 -1
- package/dist/site/schema.js +54 -2
- package/dist/styles/yaxa.css +70 -0
- 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
|
|
157
|
-
|
|
|
158
|
-
| **SaaS Suite**
|
|
159
|
-
| **
|
|
160
|
-
| **
|
|
161
|
-
| **
|
|
162
|
-
| **
|
|
163
|
-
| **
|
|
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;
|