@svgrid/enterprise 2.0.3 → 2.2.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 +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
- package/dist/node/studio.js +7889 -2460
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
|
@@ -12,7 +12,31 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { resolveIdField, titleCase, type EntityField, type EntityFieldType, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
|
|
14
14
|
import type { GeneratedFile } from './scaffold.js'
|
|
15
|
-
import type { EntityDataSource, RestSource, ShellConfig, SqlDialectKind } from './project.js'
|
|
15
|
+
import type { EntityDataSource, RestSource, ShellConfig, ShellStyle, SqlDialectKind, EntityTriggers, TriggerEvent } from './project.js'
|
|
16
|
+
import { sanitizeClassName, compileTriggerSteps, TRIGGER_EVENTS } from './project.js'
|
|
17
|
+
|
|
18
|
+
/** Build the createKitHandlers `hooks: {...}` option from an entity's triggers.
|
|
19
|
+
* before-hooks alias the mutable payload as `v`; after-hooks alias the saved row. */
|
|
20
|
+
function triggerHooksOpt(triggers: EntityTriggers | undefined): string | null {
|
|
21
|
+
if (!triggers) return null
|
|
22
|
+
const SHAPE: Record<TriggerEvent, { param: string; alias: string }> = {
|
|
23
|
+
beforeCreate: { param: '{ values }', alias: 'const v = values as Record<string, unknown>' },
|
|
24
|
+
beforeUpdate: { param: '{ patch }', alias: 'const v = patch as Record<string, unknown>' },
|
|
25
|
+
beforeDelete: { param: '{ id }', alias: 'const v = { id } as Record<string, unknown>' },
|
|
26
|
+
afterCreate: { param: '{ row }', alias: 'const v = row as Record<string, unknown>' },
|
|
27
|
+
afterUpdate: { param: '{ row }', alias: 'const v = row as Record<string, unknown>' },
|
|
28
|
+
afterDelete: { param: '{ id }', alias: 'const v = { id } as Record<string, unknown>' },
|
|
29
|
+
}
|
|
30
|
+
const entries: string[] = []
|
|
31
|
+
for (const ev of TRIGGER_EVENTS) {
|
|
32
|
+
const steps = triggers[ev]
|
|
33
|
+
if (!steps?.length) continue
|
|
34
|
+
const { param, alias } = SHAPE[ev]
|
|
35
|
+
const body = compileTriggerSteps(steps).split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
|
|
36
|
+
entries.push(` ${ev}: async (${param}) => {\n ${alias}\n${body}\n }`)
|
|
37
|
+
}
|
|
38
|
+
return entries.length ? `hooks: {\n${entries.join(',\n')},\n }` : null
|
|
39
|
+
}
|
|
16
40
|
import { generateValue } from './sample-data.js'
|
|
17
41
|
|
|
18
42
|
const pascal = (name: string): string =>
|
|
@@ -311,7 +335,7 @@ const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', {
|
|
|
311
335
|
* the route imports the shared access policy and rejects unauthorized writes -
|
|
312
336
|
* server-enforced, so a tampered client can't bypass it. When audit is on, every
|
|
313
337
|
* successful write is recorded. */
|
|
314
|
-
function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean } = {}): GeneratedFile {
|
|
338
|
+
function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers } = {}): GeneratedFile {
|
|
315
339
|
const n = namesFor(schema)
|
|
316
340
|
const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
|
|
317
341
|
const driver = SQL_DRIVERS[key]
|
|
@@ -321,8 +345,11 @@ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectK
|
|
|
321
345
|
// Every connected route validates writes against the schema server-side, and
|
|
322
346
|
// (when enabled) authorizes them by role + records an audit entry.
|
|
323
347
|
const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
|
|
324
|
-
if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals).\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action)`)
|
|
348
|
+
if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals). Reads\n // are allowed only if the role can open one of this entity's own screens.\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ${JSON.stringify(feat.screenIds ?? [])})`)
|
|
325
349
|
if (feat.audit) opts.push(`// Record every successful write to the audit trail.\n audit: (e) => recordAudit({ entity: ${JSON.stringify(schema.name)}, action: e.action, recordId: e.id, values: e.values as Record<string, unknown> | undefined, actor: String(e.event.locals?.role ?? e.event.locals?.user ?? 'system') })`)
|
|
350
|
+
// Server-enforced business rules: the entity's triggers compiled to lifecycle hooks.
|
|
351
|
+
const hooks = triggerHooksOpt(feat.triggers)
|
|
352
|
+
if (hooks) opts.push(`// Business rules, enforced server-side (a client that skips them still can't write bad data).\n ${hooks}`)
|
|
326
353
|
const handlers = `export const { POST } = createKitHandlers({\n ${opts.join(',\n ')},\n})`
|
|
327
354
|
return {
|
|
328
355
|
path: `src/routes/api/${n.route}/+server.ts`,
|
|
@@ -553,20 +580,44 @@ export function entityScreenPage(schema: EntitySchema, route?: string, title?: s
|
|
|
553
580
|
|
|
554
581
|
export type NavItem = { href: string; label: string; id?: string }
|
|
555
582
|
|
|
556
|
-
export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; dark?: boolean; access?: boolean; i18n?: boolean } = {}): GeneratedFile {
|
|
557
|
-
|
|
583
|
+
export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: ShellConfig; title?: string; themeVars?: Record<string, string>; lightVars?: Record<string, string>; darkVars?: Record<string, string>; dark?: boolean; access?: boolean; auth?: boolean; authRoutes?: string[]; authAccount?: boolean; i18n?: boolean; appClass?: string } = {}): GeneratedFile {
|
|
584
|
+
// Nav is the app's own screens; `/` just redirects to the first one, so no separate
|
|
585
|
+
// "Home" link (it would duplicate the first screen).
|
|
586
|
+
const links = nav
|
|
558
587
|
const shell = opts.shell ?? {}
|
|
559
|
-
const style:
|
|
588
|
+
const style: ShellStyle = shell.style ?? 'sidebar'
|
|
589
|
+
// App-level styling hook: an extra class on the shell root so custom.css can target the whole app.
|
|
590
|
+
const appCls = sanitizeClassName(opts.appClass)
|
|
591
|
+
const appClsAttr = appCls ? ` ${appCls}` : ''
|
|
560
592
|
const brand = (shell.brand ?? '').trim() || opts.title || 'My Studio App'
|
|
561
593
|
const footer = shell.footer === undefined ? 'Built with SvGrid Studio' : shell.footer
|
|
562
594
|
const right = style === 'sidebar' && shell.navPosition === 'right'
|
|
563
|
-
// Emit the full theme token bundle
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
595
|
+
// Emit the full theme token bundle so the generated app matches the look chosen
|
|
596
|
+
// in the designer. When both light + dark token sets are supplied we ship a
|
|
597
|
+
// built-in light/dark switcher: both sets are scoped by [data-theme] on <html>,
|
|
598
|
+
// and the bare :root falls back to the mode picked in Studio (pre-hydration).
|
|
599
|
+
const defaultMode: 'light' | 'dark' = opts.dark ? 'dark' : 'light'
|
|
600
|
+
const withAccent = (m?: Record<string, string>) => {
|
|
601
|
+
const v = { ...(m ?? {}) }
|
|
602
|
+
if (opts.accent) v['--sg-accent'] = opts.accent
|
|
603
|
+
return v
|
|
604
|
+
}
|
|
605
|
+
const declLines = (m: Record<string, string>) => Object.entries(m).map(([k, v]) => `${k}: ${v};`).join(' ')
|
|
606
|
+
const hasSwitch = !!(opts.lightVars && opts.darkVars)
|
|
607
|
+
let themeHead = ''
|
|
608
|
+
if (hasSwitch) {
|
|
609
|
+
const lv = withAccent(opts.lightVars)
|
|
610
|
+
const dv = withAccent(opts.darkVars)
|
|
611
|
+
const defRule = [declLines(defaultMode === 'dark' ? dv : lv), `color-scheme: ${defaultMode};`].join(' ')
|
|
612
|
+
const lightRule = [declLines(lv), 'color-scheme: light;'].join(' ')
|
|
613
|
+
const darkRule = [declLines(dv), 'color-scheme: dark;'].join(' ')
|
|
614
|
+
themeHead = `\n<svelte:head><style>:root { ${defRule} }\n:root[data-theme="light"] { ${lightRule} }\n:root[data-theme="dark"] { ${darkRule} }</style></svelte:head>\n`
|
|
615
|
+
} else {
|
|
616
|
+
const vars = withAccent(opts.themeVars)
|
|
617
|
+
const varLines = declLines(vars)
|
|
618
|
+
const rootRule = [varLines, opts.dark ? 'color-scheme: dark;' : ''].filter(Boolean).join(' ')
|
|
619
|
+
themeHead = rootRule ? `\n<svelte:head><style>:root { ${rootRule} }</style></svelte:head>\n` : ''
|
|
620
|
+
}
|
|
570
621
|
|
|
571
622
|
// i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
|
|
572
623
|
const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
|
|
@@ -580,18 +631,28 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
580
631
|
{#each locales as loc (loc)}<option value={loc} selected={loc === $currentLocale}>{loc}</option>{/each}
|
|
581
632
|
</select>`
|
|
582
633
|
: ''
|
|
634
|
+
const toolbarOn = shell.toolbar !== false && nav.length > 0
|
|
635
|
+
// Built-in light/dark switcher (a sun/moon button). Lives in the toolbar's tools
|
|
636
|
+
// cluster when the toolbar is on, else trails the nav links. Only rendered when
|
|
637
|
+
// both token sets are available (i.e. from the full Studio project emitter).
|
|
638
|
+
const themeToggleBtn = `<button type="button" class="sv-app__tool sv-app__theme" aria-label="Toggle color theme" title="Toggle light / dark" onclick={toggleTheme}>
|
|
639
|
+
{#if theme === 'dark'}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>{:else}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>{/if}
|
|
640
|
+
</button>`
|
|
641
|
+
// When there's no toolbar, the toggle trails the nav in its own inline slot.
|
|
642
|
+
const navToggleSlot = hasSwitch && !toolbarOn ? `\n <div class="sv-app__navtools">${themeToggleBtn}</div>` : ''
|
|
583
643
|
// Clicking any nav link closes the mobile drawer.
|
|
584
644
|
const linksMarkup = `<nav class="sv-app__links" onclick={() => (navOpen = false)}>
|
|
585
645
|
{#each nav as item (item.href)}
|
|
586
646
|
${linkGate}
|
|
587
647
|
{/each}
|
|
588
|
-
</nav>${localeSwitcher}`
|
|
648
|
+
</nav>${localeSwitcher}${navToggleSlot}`
|
|
589
649
|
const footMarkup = footer ? `\n <span class="sv-app__foot">{footer}</span>` : ''
|
|
590
650
|
const footConst = footer ? `\n const footer = ${JSON.stringify(footer)}` : ''
|
|
591
651
|
// Sidebar-only: a collapsible sidebar that docks on wide screens and becomes an
|
|
592
652
|
// off-canvas drawer when collapsed or on tablet/phone (<= 1024px). The collapse
|
|
593
|
-
// choice persists; tablet/phone start collapsed.
|
|
594
|
-
|
|
653
|
+
// choice persists; tablet/phone start collapsed. top-nav and bottom-nav are
|
|
654
|
+
// always-visible bars with no collapse/drawer state.
|
|
655
|
+
const sideState = style !== 'sidebar' ? '' : `
|
|
595
656
|
let collapsed = $state(false)
|
|
596
657
|
let narrow = $state(false)
|
|
597
658
|
const drawer = $derived(collapsed || narrow)
|
|
@@ -629,7 +690,6 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
629
690
|
// App-chrome toolbar (docked at the top of the content area for both layouts):
|
|
630
691
|
// a functional quick-search over the app's screens + an account cluster. Reads as
|
|
631
692
|
// a real product header. Opt out with `shell.toolbar === false`.
|
|
632
|
-
const toolbarOn = shell.toolbar !== false && nav.length > 0
|
|
633
693
|
const initials = ((brand.match(/\b[A-Za-z0-9]/g) ?? []).slice(0, 2).join('') || 'A').toUpperCase()
|
|
634
694
|
const brandSlug = brand.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'app'
|
|
635
695
|
const resultLabel = opts.i18n ? `{r.id ? $t('nav.' + r.id, r.label) : r.label}` : '{r.label}'
|
|
@@ -648,6 +708,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
648
708
|
{/if}
|
|
649
709
|
</div>
|
|
650
710
|
<div class="sv-app__tools">
|
|
711
|
+
${hasSwitch ? themeToggleBtn : ''}
|
|
651
712
|
<div class="sv-app__pop">
|
|
652
713
|
<button type="button" class="sv-app__tool" aria-label="Notifications" aria-expanded={bellOpen} onclick={() => { bellOpen = !bellOpen; menuOpen = false }}>
|
|
653
714
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
|
@@ -667,11 +728,15 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
667
728
|
<button type="button" class="sv-app__avatar" title={brand} aria-label="Account" aria-expanded={menuOpen} onclick={() => { menuOpen = !menuOpen; bellOpen = false }}>{initials}</button>
|
|
668
729
|
{#if menuOpen}
|
|
669
730
|
<div class="sv-app__menu sv-app__menu--acct" role="menu">
|
|
670
|
-
<div class="sv-app__acct-head"><span class="sv-app__avatar sv-app__avatar--lg" aria-hidden="true">{initials}</span><div class="sv-app__acct-id"><strong
|
|
731
|
+
<div class="sv-app__acct-head"><span class="sv-app__avatar sv-app__avatar--lg" aria-hidden="true">{initials}</span><div class="sv-app__acct-id"><strong>${opts.auth ? '{data?.user?.name ?? brand}' : '{brand}'}</strong><span>${opts.auth ? '{data?.user?.email ?? acctEmail}' : '{acctEmail}'}</span></div></div>
|
|
671
732
|
<a class="sv-app__menu-item" href="/" role="menuitem" onclick={() => (menuOpen = false)}>Dashboard</a>
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
733
|
+
${opts.authAccount
|
|
734
|
+
? `<a class="sv-app__menu-item" href="/account" role="menuitem" onclick={() => (menuOpen = false)}>Account</a>`
|
|
735
|
+
: `<button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Profile</button>
|
|
736
|
+
<button type="button" class="sv-app__menu-item" role="menuitem" onclick={() => (menuOpen = false)}>Settings</button>`}
|
|
737
|
+
${opts.auth
|
|
738
|
+
? `<form method="POST" action="/logout" class="sv-app__signout"><button type="submit" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem">Sign out</button></form>`
|
|
739
|
+
: `<button type="button" class="sv-app__menu-item sv-app__menu-item--danger" role="menuitem" onclick={() => (menuOpen = false)}>Sign out</button>`}
|
|
675
740
|
</div>
|
|
676
741
|
{/if}
|
|
677
742
|
</div>
|
|
@@ -686,14 +751,22 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
686
751
|
</main>`
|
|
687
752
|
|
|
688
753
|
const body = style === 'top-nav'
|
|
689
|
-
? `<div class="sv-app sv-app--top">
|
|
754
|
+
? `<div class="sv-app sv-app--top${appClsAttr}">
|
|
690
755
|
<header class="sv-app__bar">
|
|
691
756
|
${brandLink}
|
|
692
757
|
${linksMarkup}
|
|
693
758
|
</header>
|
|
694
759
|
${mainMarkup}${footer ? `\n <footer class="sv-app__footbar">{footer}</footer>` : ''}
|
|
695
760
|
</div>`
|
|
696
|
-
:
|
|
761
|
+
: style === 'bottom-nav'
|
|
762
|
+
? `<div class="sv-app sv-app--bottom${appClsAttr}">
|
|
763
|
+
${mainMarkup}
|
|
764
|
+
<footer class="sv-app__bar sv-app__bar--bottom">
|
|
765
|
+
${brandLink}
|
|
766
|
+
${linksMarkup}
|
|
767
|
+
</footer>
|
|
768
|
+
</div>`
|
|
769
|
+
: `<div class="sv-app sv-app--side${right ? ' sv-app--right' : ''}${appClsAttr}" class:is-drawer={drawer} class:is-navopen={navOpen}>
|
|
697
770
|
${mobileBar}
|
|
698
771
|
<aside class="sv-app__side">
|
|
699
772
|
<div class="sv-app__sidehead">
|
|
@@ -717,6 +790,24 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
717
790
|
.sv-app__bar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; }
|
|
718
791
|
.sv-app__links { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; -webkit-overflow-scrolling: touch; }
|
|
719
792
|
.sv-app__link { white-space: nowrap; }
|
|
793
|
+
}`
|
|
794
|
+
: style === 'bottom-nav'
|
|
795
|
+
? ` .sv-app--bottom { display: flex; flex-direction: column; min-height: 100vh; }
|
|
796
|
+
.sv-app__bar--bottom {
|
|
797
|
+
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
|
|
798
|
+
display: flex; align-items: center; gap: 20px; padding: 12px 22px calc(12px + env(safe-area-inset-bottom, 0px));
|
|
799
|
+
border-top: 1px solid color-mix(in srgb, var(--sg-fg, #0f172a) 16%, var(--sg-border, #e6e8ec));
|
|
800
|
+
background: var(--sg-header-bg, #f8fafc);
|
|
801
|
+
}
|
|
802
|
+
.sv-app__bar--bottom .sv-app__links { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
|
|
803
|
+
/* Clears the fixed bottom bar so page content never renders underneath it. */
|
|
804
|
+
.sv-app--bottom .sv-app__main { padding-bottom: 70px; }
|
|
805
|
+
/* Mobile: brand drops out so the tab strip stays roomy; ties overflow to a horizontal scroll if there are many screens. */
|
|
806
|
+
@media (max-width: 640px) {
|
|
807
|
+
.sv-app__bar--bottom { gap: 10px; padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px)); }
|
|
808
|
+
.sv-app__bar--bottom .sv-app__brand { display: none; }
|
|
809
|
+
.sv-app__bar--bottom .sv-app__links { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start; -webkit-overflow-scrolling: touch; }
|
|
810
|
+
.sv-app__bar--bottom .sv-app__link { white-space: nowrap; }
|
|
720
811
|
}`
|
|
721
812
|
: ` .sv-app--side { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; }
|
|
722
813
|
.sv-app--side.sv-app--right { grid-template-columns: minmax(0, 1fr) 240px; }
|
|
@@ -750,14 +841,30 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
750
841
|
description: `App shell (${style}): nav linking every screen.`,
|
|
751
842
|
contents: `<script lang="ts">
|
|
752
843
|
import '../app.css'
|
|
753
|
-
import
|
|
844
|
+
import '../custom.css'
|
|
845
|
+
import { page } from '$app/stores'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}${opts.auth ? `\n import type { LayoutData } from './$types'` : ''}
|
|
754
846
|
|
|
755
|
-
let { children } = $props()
|
|
847
|
+
let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
|
|
756
848
|
const nav = ${JSON.stringify(links)}
|
|
757
849
|
const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
|
|
758
|
-
let navOpen = $state(false)
|
|
850
|
+
let navOpen = $state(false)${opts.auth && opts.access ? `\n // Seed the client role store from the signed-in session (server-resolved).\n $effect(() => { if (data?.role) currentRole.set(data.role as never) })` : ''}
|
|
759
851
|
// Close the mobile drawer whenever the route changes.
|
|
760
|
-
$effect(() => { void $page.url.pathname; navOpen = false })${
|
|
852
|
+
$effect(() => { void $page.url.pathname; navOpen = false })${hasSwitch ? `
|
|
853
|
+
// Light/dark switcher: defaults to the mode picked in Studio, then honours the
|
|
854
|
+
// visitor's saved choice. Applies via [data-theme] on <html> (see the token
|
|
855
|
+
// sets in <svelte:head>), and persists per browser.
|
|
856
|
+
let theme = $state<'light' | 'dark'>('${defaultMode}')
|
|
857
|
+
function applyTheme(t: 'light' | 'dark') {
|
|
858
|
+
theme = t
|
|
859
|
+
try { document.documentElement.dataset.theme = t } catch (_) { /* no DOM */ }
|
|
860
|
+
try { localStorage.setItem('svapp:theme', t) } catch (_) { /* storage blocked */ }
|
|
861
|
+
}
|
|
862
|
+
function toggleTheme() { applyTheme(theme === 'dark' ? 'light' : 'dark') }
|
|
863
|
+
$effect(() => {
|
|
864
|
+
let t: 'light' | 'dark' = '${defaultMode}'
|
|
865
|
+
try { const s = localStorage.getItem('svapp:theme'); if (s === 'light' || s === 'dark') t = s } catch (_) { /* storage blocked */ }
|
|
866
|
+
applyTheme(t)
|
|
867
|
+
})` : ''}${sideState}${toolbarOn ? `
|
|
761
868
|
// App-chrome quick-search: filter the screens by label as you type.
|
|
762
869
|
const initials = ${JSON.stringify(initials)}
|
|
763
870
|
const acctEmail = ${JSON.stringify(`admin@${brandSlug}.com`)}
|
|
@@ -785,7 +892,11 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
785
892
|
</script>
|
|
786
893
|
${themeHead}
|
|
787
894
|
|
|
895
|
+
${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes($page.url.pathname)}
|
|
896
|
+
{@render children()}
|
|
897
|
+
{:else}
|
|
788
898
|
${body}
|
|
899
|
+
{/if}` : body}
|
|
789
900
|
|
|
790
901
|
<style>
|
|
791
902
|
${styles}
|
|
@@ -831,7 +942,13 @@ ${styles}
|
|
|
831
942
|
.sv-app__menu-item { display: block; width: 100%; text-align: left; padding: 8px 10px; font: inherit; font-size: 13px; color: var(--sg-fg, #334155); background: none; border: none; border-radius: 8px; text-decoration: none; cursor: pointer; }
|
|
832
943
|
.sv-app__menu-item:hover { background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, transparent); color: var(--sg-accent, #6366f1); }
|
|
833
944
|
.sv-app__menu-item--danger:hover { background: color-mix(in srgb, #ef4444 12%, transparent); color: #ef4444; }
|
|
945
|
+
.sv-app__signout { margin: 0; display: block; }
|
|
946
|
+
.sv-app__signout .sv-app__menu-item { width: 100%; }
|
|
834
947
|
.sv-app__locale { margin-top: 10px; padding: 5px 8px; font: inherit; font-size: 12.5px; color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 8px; }
|
|
948
|
+
.sv-app__theme { align-items: center; justify-content: center; cursor: pointer; }
|
|
949
|
+
/* No-toolbar fallback slot: trails the nav, pushed to the far edge in row bars. */
|
|
950
|
+
.sv-app__navtools { display: flex; align-items: center; }
|
|
951
|
+
.sv-app--top .sv-app__navtools, .sv-app__bar--bottom .sv-app__navtools { margin-left: auto; }
|
|
835
952
|
/* Mobile bar + drawer chrome (hidden on desktop; media queries above switch it on) */
|
|
836
953
|
.sv-app__mobilebar { display: none; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e6e8ec); background: var(--sg-header-bg, #f8fafc); position: sticky; top: 0; z-index: 50; }
|
|
837
954
|
.sv-app__burger { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 9px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
@@ -894,13 +1011,13 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
|
|
|
894
1011
|
/** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
|
|
895
1012
|
export function emitEntityModules(
|
|
896
1013
|
schemas: EntitySchema[],
|
|
897
|
-
opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean } = {},
|
|
1014
|
+
opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers> } = {},
|
|
898
1015
|
): { files: GeneratedFile[]; prepared: EntitySchema[] } {
|
|
899
1016
|
const { entries, seed } = prepareEntities(schemas)
|
|
900
1017
|
const { file: data, needs } = dataModule(entries, seed, opts.sources)
|
|
901
1018
|
const files: GeneratedFile[] = [schemasModule(entries), data]
|
|
902
1019
|
if (needs.supabase) files.push(connectionsModule(needs))
|
|
903
|
-
for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled }))
|
|
1020
|
+
for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, { access: opts.accessEnabled, audit: opts.auditEnabled, screenIds: opts.screensByEntity?.get(r.schema.name) ?? [], triggers: opts.triggers?.[r.schema.name] }))
|
|
904
1021
|
return { files, prepared: entries.map((e) => e.schema) }
|
|
905
1022
|
}
|
|
906
1023
|
|