@happyvertical/smrt-template-sveltekit 0.38.26 → 0.39.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/AGENTS.md +45 -26
- package/README.md +118 -80
- package/index.js +4 -4
- package/package.json +10 -8
- package/template/.env.example +4 -8
- package/template/AGENTS.md +29 -28
- package/template/README.md +232 -398
- package/template/package.json +21 -12
- package/template/smrt.config.ts +1 -16
- package/template/src/app.d.ts +5 -4
- package/template/src/app.html +0 -1
- package/template/src/hooks.server.ts +27 -111
- package/template/src/lib/objects/Item.ts +23 -22
- package/template/src/lib/objects/index.ts +1 -1
- package/template/src/lib/server/smrt.ts +14 -22
- package/template/src/lib/server/tenancy.ts +81 -234
- package/template/src/routes/+layout.server.ts +12 -66
- package/template/src/routes/+layout.svelte +64 -76
- package/template/src/routes/+page.server.ts +67 -70
- package/template/src/routes/+page.svelte +141 -109
- package/template/src/routes/settings/+page.svelte +1 -1
- package/template/vite.config.ts +14 -4
- package/LICENSE +0 -7
|
@@ -1,267 +1,114 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tenant
|
|
2
|
+
* Tenant selection for SvelteKit requests.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* function without forking. The shipped default is subdomain-based:
|
|
4
|
+
* Selection and authorization are deliberately separate:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* - this module maps a trusted URL shape (the leading subdomain) to an active
|
|
7
|
+
* tenant record;
|
|
8
|
+
* - `createSessionHandler({ enterTenantContext: true })` authorizes the signed-
|
|
9
|
+
* in user's active membership and establishes the request tenant context.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* @example Swap to path-prefix resolution
|
|
17
|
-
* ```ts
|
|
18
|
-
* // src/lib/server/tenancy.ts (your project)
|
|
19
|
-
* import { createTenantResolver, pathPrefixStrategy } from './tenancy';
|
|
20
|
-
*
|
|
21
|
-
* export const resolveTenant = createTenantResolver(pathPrefixStrategy);
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* @example Swap to header-based resolution
|
|
25
|
-
* ```ts
|
|
26
|
-
* import { createTenantResolver, headerStrategy } from './tenancy';
|
|
27
|
-
*
|
|
28
|
-
* export const resolveTenant = createTenantResolver(
|
|
29
|
-
* headerStrategy({ headerName: 'x-tenant-id' }),
|
|
30
|
-
* );
|
|
31
|
-
* ```
|
|
32
|
-
*
|
|
33
|
-
* @example Compose your own
|
|
34
|
-
* ```ts
|
|
35
|
-
* import { createTenantResolver, subdomainStrategy } from './tenancy';
|
|
36
|
-
*
|
|
37
|
-
* export const resolveTenant = createTenantResolver((event) => {
|
|
38
|
-
* // Try subdomain first, then fall back to a header
|
|
39
|
-
* const fromSubdomain = subdomainStrategy(event);
|
|
40
|
-
* if (fromSubdomain.tenantId) return fromSubdomain;
|
|
41
|
-
* return { tenantId: event.request.headers.get('x-tenant-id') };
|
|
42
|
-
* });
|
|
43
|
-
* ```
|
|
11
|
+
* A selected tenant is useful for login and membership-gated switch flows, but
|
|
12
|
+
* it never becomes ambient query authority by itself. In particular, this
|
|
13
|
+
* resolver ignores client-provided tenant headers.
|
|
44
14
|
*/
|
|
45
15
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
16
|
+
import {
|
|
17
|
+
TenantCollection,
|
|
18
|
+
TenantStatus,
|
|
19
|
+
} from '@happyvertical/smrt-users';
|
|
20
|
+
|
|
21
|
+
import { getSmrtConfig } from './smrt.js';
|
|
22
|
+
|
|
51
23
|
export interface TenantResolverEvent {
|
|
52
24
|
url: URL;
|
|
53
25
|
request: { headers: Headers };
|
|
54
|
-
params?: Record<string, string | undefined>;
|
|
55
26
|
}
|
|
56
27
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
*/
|
|
60
|
-
export interface TenantResolution {
|
|
28
|
+
export interface TenantSelection {
|
|
29
|
+
/** Database UUID for an active tenant, or null when none resolves. */
|
|
61
30
|
tenantId: string | null;
|
|
31
|
+
/** URL-derived slug. Informational until membership authorizes a switch. */
|
|
32
|
+
tenantSlug: string | null;
|
|
62
33
|
}
|
|
63
34
|
|
|
64
|
-
/**
|
|
65
|
-
* A resolver strategy is just a function from event → resolution. Sync or
|
|
66
|
-
* async — the dispatcher awaits the return value.
|
|
67
|
-
*/
|
|
68
|
-
export type TenantResolverStrategy = (
|
|
69
|
-
event: TenantResolverEvent,
|
|
70
|
-
) => TenantResolution | Promise<TenantResolution>;
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Hostnames that should never produce a tenant id, regardless of how many
|
|
74
|
-
* dots they contain. Treats `localhost`, `127.0.0.1`, and `::1` as
|
|
75
|
-
* tenant-less so local dev "just works" until you set up a wildcard DNS
|
|
76
|
-
* entry like `*.demo.local → 127.0.0.1`.
|
|
77
|
-
*/
|
|
78
35
|
const ROOT_LIKE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
36
|
+
const RESERVED_SUBDOMAINS = new Set(['www', 'api', 'app', 'admin']);
|
|
79
37
|
|
|
80
38
|
/**
|
|
81
|
-
*
|
|
82
|
-
* leading position. Consumers can pass their own list to
|
|
83
|
-
* {@link subdomainStrategyWith} if they need to extend this.
|
|
84
|
-
*/
|
|
85
|
-
const DEFAULT_RESERVED_SUBDOMAINS = new Set(['www', 'api', 'app']);
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Strategy: extract the tenant id from the leading subdomain.
|
|
39
|
+
* Pure URL parser used by the default resolver.
|
|
89
40
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* - `www.demo.local` → `{ tenantId: null }` (reserved)
|
|
94
|
-
* - `demo.local` → `{ tenantId: null }` (no leading subdomain)
|
|
95
|
-
* - `localhost` → `{ tenantId: null }`
|
|
96
|
-
* - `127.0.0.1` or any IPv4/IPv6 → `{ tenantId: null }`
|
|
41
|
+
* Set `TENANT_BASE_DOMAIN` in production so multi-label public suffixes are
|
|
42
|
+
* never guessed. Without it, the fallback is intended only for local domains
|
|
43
|
+
* such as `acme.demo.local`.
|
|
97
44
|
*/
|
|
98
|
-
export
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
* The supplied `reservedSubdomains` are **merged with** the built-in
|
|
109
|
-
* defaults (`www`, `api`, `app`) — `www`, `api`, and `app` always stay
|
|
110
|
-
* reserved regardless of what you pass. Pass `[]` or omit the option
|
|
111
|
-
* if you only need the defaults; the merge is additive-only. Replacing
|
|
112
|
-
* the default list outright is not currently supported via this
|
|
113
|
-
* strategy — fork the function or wire your own `TenantResolverStrategy`
|
|
114
|
-
* if you need that.
|
|
115
|
-
*
|
|
116
|
-
* `baseDomain` (strongly recommended for production) anchors the apex.
|
|
117
|
-
* When set, the strategy strips the matching suffix and treats whatever
|
|
118
|
-
* leading labels remain as the tenant. `example.co.uk` with
|
|
119
|
-
* `baseDomain: 'example.co.uk'` → no tenant; `acme.example.co.uk` →
|
|
120
|
-
* `'acme'`. Without `baseDomain` the strategy falls back to a label-
|
|
121
|
-
* count heuristic that works for `.com`/`.net`/`.dev`-style single-
|
|
122
|
-
* label TLDs but mis-identifies multi-label public suffixes (`.co.uk`,
|
|
123
|
-
* `.com.au`, `.gov.uk`, …) as tenant subdomains.
|
|
124
|
-
*/
|
|
125
|
-
export function subdomainStrategyWith(opts?: {
|
|
126
|
-
reservedSubdomains?: Iterable<string>;
|
|
127
|
-
/**
|
|
128
|
-
* Apex domain to anchor tenant detection against. Highly recommended
|
|
129
|
-
* for production deployments on multi-label public suffixes
|
|
130
|
-
* (`example.co.uk`, `example.com.au`, …). When set, a request for
|
|
131
|
-
* exactly that host returns `{ tenantId: null }`, and a request for
|
|
132
|
-
* `<tenant>.<baseDomain>` returns `{ tenantId: '<tenant>' }`. Subdomain
|
|
133
|
-
* resolution stops being label-count-dependent.
|
|
134
|
-
*/
|
|
135
|
-
baseDomain?: string;
|
|
136
|
-
}): TenantResolverStrategy {
|
|
137
|
-
const reserved = new Set<string>(DEFAULT_RESERVED_SUBDOMAINS);
|
|
138
|
-
if (opts?.reservedSubdomains) {
|
|
139
|
-
for (const s of opts.reservedSubdomains) {
|
|
140
|
-
reserved.add(s.toLowerCase());
|
|
141
|
-
}
|
|
45
|
+
export function selectTenantSlug(
|
|
46
|
+
url: URL,
|
|
47
|
+
baseDomain = process.env.TENANT_BASE_DOMAIN,
|
|
48
|
+
): string | null {
|
|
49
|
+
const hostname = url.hostname.toLowerCase();
|
|
50
|
+
if (
|
|
51
|
+
ROOT_LIKE_HOSTS.has(hostname) ||
|
|
52
|
+
/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)
|
|
53
|
+
) {
|
|
54
|
+
return null;
|
|
142
55
|
}
|
|
143
|
-
const baseDomain = opts?.baseDomain?.toLowerCase().replace(/^\.+|\.+$/g, '');
|
|
144
|
-
|
|
145
|
-
return (event) => {
|
|
146
|
-
const hostname = event.url.hostname.toLowerCase();
|
|
147
|
-
|
|
148
|
-
if (ROOT_LIKE_HOSTS.has(hostname)) {
|
|
149
|
-
return { tenantId: null };
|
|
150
|
-
}
|
|
151
56
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
const suffix = `.${baseDomain}`;
|
|
166
|
-
if (hostname.endsWith(suffix)) {
|
|
167
|
-
const leading = hostname.slice(0, -suffix.length);
|
|
168
|
-
// Take only the first label of whatever's left so
|
|
169
|
-
// `acme.beta.example.co.uk` still resolves to `acme`.
|
|
170
|
-
const candidate = leading.split('.')[0];
|
|
171
|
-
if (!candidate || reserved.has(candidate)) {
|
|
172
|
-
return { tenantId: null };
|
|
173
|
-
}
|
|
174
|
-
return { tenantId: candidate };
|
|
175
|
-
}
|
|
176
|
-
// Host doesn't match the configured apex — out of scope for this
|
|
177
|
-
// strategy, treat as tenant-less rather than guess.
|
|
178
|
-
return { tenantId: null };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Fallback path: no baseDomain configured. Use a label-count
|
|
182
|
-
// heuristic that works for single-label TLDs. **This is incorrect
|
|
183
|
-
// for multi-label public suffixes** (`example.co.uk` has three
|
|
184
|
-
// labels and would be misread as `example` being a tenant on the
|
|
185
|
-
// `co.uk` root). Pass `baseDomain` to opt out of the heuristic on
|
|
186
|
-
// any production deployment that uses such a TLD.
|
|
57
|
+
const normalizedBase = baseDomain
|
|
58
|
+
?.trim()
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/^\.+|\.+$/g, '');
|
|
61
|
+
|
|
62
|
+
let candidate: string | undefined;
|
|
63
|
+
if (normalizedBase) {
|
|
64
|
+
if (hostname === normalizedBase) return null;
|
|
65
|
+
const suffix = `.${normalizedBase}`;
|
|
66
|
+
if (!hostname.endsWith(suffix)) return null;
|
|
67
|
+
candidate = hostname.slice(0, -suffix.length).split('.')[0];
|
|
68
|
+
} else {
|
|
187
69
|
const labels = hostname.split('.');
|
|
70
|
+
if (labels.length < 3) return null;
|
|
71
|
+
candidate = labels[0];
|
|
72
|
+
}
|
|
188
73
|
|
|
189
|
-
|
|
190
|
-
// `demo.local` (2 labels) has no subdomain.
|
|
191
|
-
if (labels.length < 3) {
|
|
192
|
-
return { tenantId: null };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const candidate = labels[0];
|
|
196
|
-
if (!candidate || reserved.has(candidate)) {
|
|
197
|
-
return { tenantId: null };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return { tenantId: candidate };
|
|
201
|
-
};
|
|
74
|
+
return candidate && !RESERVED_SUBDOMAINS.has(candidate) ? candidate : null;
|
|
202
75
|
}
|
|
203
76
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
*
|
|
213
|
-
* // matches /tenant/acme/... → tenantId='acme'
|
|
214
|
-
* createTenantResolver(pathPrefixStrategy({ prefix: '/tenant/' }));
|
|
215
|
-
* ```
|
|
216
|
-
*/
|
|
217
|
-
export function pathPrefixStrategy(opts?: {
|
|
218
|
-
prefix?: string;
|
|
219
|
-
}): TenantResolverStrategy {
|
|
220
|
-
const prefix = opts?.prefix ?? '/t/';
|
|
221
|
-
return (event) => {
|
|
222
|
-
const path = event.url.pathname;
|
|
223
|
-
if (!path.startsWith(prefix)) {
|
|
224
|
-
return { tenantId: null };
|
|
225
|
-
}
|
|
226
|
-
const rest = path.slice(prefix.length);
|
|
227
|
-
const slug = rest.split('/')[0];
|
|
228
|
-
return { tenantId: slug && slug.length > 0 ? slug : null };
|
|
229
|
-
};
|
|
77
|
+
function isMissingTenantTable(error: unknown): boolean {
|
|
78
|
+
if (!(error instanceof Error)) return false;
|
|
79
|
+
const code = (error as Error & { code?: string }).code;
|
|
80
|
+
return (
|
|
81
|
+
code === '42P01' ||
|
|
82
|
+
error.message.includes('relation "tenants" does not exist') ||
|
|
83
|
+
error.message.includes('no such table: tenants')
|
|
84
|
+
);
|
|
230
85
|
}
|
|
231
86
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}): TenantResolverStrategy {
|
|
243
|
-
const headerName = opts?.headerName ?? 'x-tenant-id';
|
|
244
|
-
return (event) => {
|
|
245
|
-
const value = event.request.headers.get(headerName);
|
|
246
|
-
return { tenantId: value && value.length > 0 ? value : null };
|
|
247
|
-
};
|
|
87
|
+
async function findActiveTenantId(slug: string): Promise<string | null> {
|
|
88
|
+
try {
|
|
89
|
+
const tenants = await TenantCollection.create(getSmrtConfig('Tenant'));
|
|
90
|
+
const tenant = await tenants.findBySlug(slug);
|
|
91
|
+
return tenant?.status === TenantStatus.ACTIVE ? (tenant.id ?? null) : null;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// A fresh project can render before its first `pnpm db:migrate`.
|
|
94
|
+
if (isMissingTenantTable(error)) return null;
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
248
97
|
}
|
|
249
98
|
|
|
250
99
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
100
|
+
* Default selection extension point. Replace this function when your tenant
|
|
101
|
+
* key comes from a path, signed cookie, or trusted gateway assertion. Preserve
|
|
102
|
+
* the rule that selection alone does not establish authorization context.
|
|
254
103
|
*/
|
|
255
|
-
export function
|
|
256
|
-
|
|
257
|
-
):
|
|
258
|
-
|
|
259
|
-
|
|
104
|
+
export async function resolveTenant(
|
|
105
|
+
event: TenantResolverEvent,
|
|
106
|
+
): Promise<TenantSelection> {
|
|
107
|
+
const tenantSlug = selectTenantSlug(event.url);
|
|
108
|
+
if (!tenantSlug) return { tenantId: null, tenantSlug: null };
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
tenantId: await findActiveTenantId(tenantSlug),
|
|
112
|
+
tenantSlug,
|
|
260
113
|
};
|
|
261
114
|
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Default resolver used by `hooks.server.ts`. Swap the argument to
|
|
265
|
-
* `createTenantResolver()` (or replace this export) to change strategies.
|
|
266
|
-
*/
|
|
267
|
-
export const resolveTenant = createTenantResolver(subdomainStrategy);
|
|
@@ -1,69 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Root layout server load — builds the AdminShell tenant navigation.
|
|
3
|
-
*
|
|
4
|
-
* The WASD AdminShell (`src/routes/+layout.svelte`) is the default chrome for
|
|
5
|
-
* this app. Its left "tenant" rail is driven by a nav tree derived from the
|
|
6
|
-
* SMRT manifest, and that tree is built HERE — server-side — rather than with a
|
|
7
|
-
* client-side fetch. This mirrors the home page's data pattern
|
|
8
|
-
* (`+page.server.ts`): data is produced during SSR, serialized into the initial
|
|
9
|
-
* HTML, and hydrated on the client with no duplicate request.
|
|
10
|
-
*
|
|
11
|
-
* `tenantNavFromManifest()` is a pure function (data in → data out). It reads
|
|
12
|
-
* the same generated manifest the runtime uses (`.smrt/manifest.json`, written
|
|
13
|
-
* by `smrtPlugin()` and already loaded by `src/lib/server/smrt.ts`), so the nav
|
|
14
|
-
* stays in sync with your `@smrt()` classes automatically instead of being
|
|
15
|
-
* hand-maintained.
|
|
16
|
-
*
|
|
17
|
-
* This load reads no URL/params and declares no `depends()`, so after its
|
|
18
|
-
* initial SSR run SvelteKit does not re-run it on client navigations — only on
|
|
19
|
-
* a full reload or `invalidateAll()`. The manifest-derived nav is therefore
|
|
20
|
-
* effectively static for the session, which is fine (the manifest is fixed at
|
|
21
|
-
* build time). It is independent of each page's own load, so the home page's
|
|
22
|
-
* `depends('smrt:items')` / `invalidate('smrt:items')` refresh flow is
|
|
23
|
-
* untouched by this file.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { existsSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
28
|
-
|
|
29
|
-
import { loadManifestFromPathSync } from '@happyvertical/smrt-core/manifest';
|
|
30
|
-
import {
|
|
31
|
-
type ShellNavItem,
|
|
32
|
-
type SmrtManifestLike,
|
|
33
|
-
tenantNavFromManifest,
|
|
34
|
-
} from '@happyvertical/smrt-svelte/workspace';
|
|
35
1
|
import type { LayoutServerLoad } from './$types';
|
|
36
2
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// (`SmrtManifestLike`). The core `SmartObjectManifest` carries richer
|
|
50
|
-
// field types (e.g. `ApiConfig` has no index signature), so narrow it to
|
|
51
|
-
// the structural shape the pure helper consumes.
|
|
52
|
-
//
|
|
53
|
-
// `basePath: ''` emits page-style hrefs (`/items`) for the developer to
|
|
54
|
-
// wire to their own list routes. `sectionHints` groups classes by
|
|
55
|
-
// package into readable section titles — extend it as you add packages.
|
|
56
|
-
// `NavSection[]` is structurally a superset of `ShellNavItem[]`, so it
|
|
57
|
-
// feeds straight into <TenantNav items={nav}>.
|
|
58
|
-
nav = tenantNavFromManifest(manifest as unknown as SmrtManifestLike, {
|
|
59
|
-
basePath: '',
|
|
60
|
-
sectionHints: {
|
|
61
|
-
'@happyvertical/smrt-content': 'Content',
|
|
62
|
-
'@happyvertical/smrt-users': 'Users',
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return { nav };
|
|
3
|
+
/**
|
|
4
|
+
* Keep session and tenant state server-owned. The selected tenant is displayed
|
|
5
|
+
* separately from the tenant authorized by the active session.
|
|
6
|
+
*/
|
|
7
|
+
export const load: LayoutServerLoad = async ({ locals }) => {
|
|
8
|
+
return {
|
|
9
|
+
session: {
|
|
10
|
+
authenticated: Boolean(locals.user),
|
|
11
|
+
activeTenantId: locals.tenantId,
|
|
12
|
+
selectedTenantSlug: locals.selectedTenantSlug,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
69
15
|
};
|
|
@@ -1,110 +1,98 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { page } from '$app/state';
|
|
3
|
+
import { Provider } from '@happyvertical/smrt-svelte';
|
|
3
4
|
import {
|
|
4
5
|
AdminShell,
|
|
5
6
|
AppScopePanel,
|
|
6
|
-
type
|
|
7
|
-
SystemStatusChips,
|
|
7
|
+
type ShellNavItem,
|
|
8
8
|
TenantNav,
|
|
9
9
|
} from '@happyvertical/smrt-svelte/workspace';
|
|
10
10
|
import { ThemeProvider } from '@happyvertical/smrt-ui/themes';
|
|
11
|
-
// Self-hosted @font-face rules for the SMRT type stack (Space Grotesk /
|
|
12
|
-
// Inter / JetBrains Mono woff2). `<ThemeProvider>` supplies every `--smrt-*`
|
|
13
|
-
// token variable at runtime, but not the font FILES — this import loads them
|
|
14
|
-
// so `--smrt-font-family` renders as the real stack instead of falling back
|
|
15
|
-
// to system-ui. Bundled with the app; no CDN request.
|
|
16
11
|
import '@happyvertical/smrt-ui/themes/styles/fonts.css';
|
|
17
12
|
import type { LayoutProps } from './$types';
|
|
18
13
|
|
|
19
|
-
// `data.nav` is built server-side in `+layout.server.ts` from the SMRT
|
|
20
|
-
// manifest and hydrated here — no client-side nav fetch. `children` is the
|
|
21
|
-
// active page, which renders inside AdminShell's `<main>` and keeps its own
|
|
22
|
-
// server load + `invalidate` flow (see `+page.server.ts`).
|
|
23
14
|
let { data, children }: LayoutProps = $props();
|
|
24
15
|
|
|
25
|
-
// Both wrappers are SSR-safe. ThemeProvider emits its `--smrt-*` token
|
|
26
|
-
// variables as an inline style computed during render (no `window`), so the
|
|
27
|
-
// tokens are present in the server HTML with no unstyled flash; it only reads
|
|
28
|
-
// `matchMedia` after mount to resolve `colorScheme="system"`. AdminShell's
|
|
29
|
-
// public core likewise renders statically and only wires WASD hotkeys /
|
|
30
|
-
// localStorage persistence after mount. Nothing here reads `window` or
|
|
31
|
-
// `localStorage` at module or render time. `$app/state`'s `page` is populated
|
|
32
|
-
// on the server too, so `currentHref` is correct on first paint.
|
|
33
16
|
const currentHref = $derived(page.url.pathname);
|
|
17
|
+
const activeTenantLabel = $derived(
|
|
18
|
+
data.session.activeTenantId ? 'Authorized session tenant' : 'No active tenant',
|
|
19
|
+
);
|
|
34
20
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
|
|
21
|
+
// Add application routes here. Generated REST routes live under /api and do
|
|
22
|
+
// not automatically imply a human-facing page.
|
|
23
|
+
const nav: ShellNavItem[] = [
|
|
24
|
+
{
|
|
25
|
+
href: '/',
|
|
26
|
+
label: 'Items',
|
|
27
|
+
description: 'The example s-m-r-t object',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
href: '/settings',
|
|
31
|
+
label: 'Settings',
|
|
32
|
+
description: 'Workspace layout and shortcuts',
|
|
33
|
+
},
|
|
45
34
|
];
|
|
46
35
|
</script>
|
|
47
36
|
|
|
48
|
-
|
|
49
|
-
ThemeProvider
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
-->
|
|
55
|
-
<ThemeProvider colorScheme="system" persist={true}>
|
|
56
|
-
<AdminShell title="SMRT App" subtitle="SvelteKit" storageKey="smrt-app-shell">
|
|
57
|
-
{#snippet appPanel()}
|
|
58
|
-
<AppScopePanel
|
|
59
|
-
appName="SMRT App"
|
|
60
|
-
tenantName="Local development"
|
|
61
|
-
environment="local"
|
|
62
|
-
showSettings={false}
|
|
37
|
+
<Provider>
|
|
38
|
+
<ThemeProvider preset="smrt" colorScheme="system" persist={true}>
|
|
39
|
+
<AdminShell
|
|
40
|
+
title="s-m-r-t app"
|
|
41
|
+
subtitle="SvelteKit"
|
|
42
|
+
storageKey="smrt-app-shell"
|
|
63
43
|
>
|
|
64
|
-
{#snippet
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
44
|
+
{#snippet appPanel()}
|
|
45
|
+
<AppScopePanel
|
|
46
|
+
appName="s-m-r-t app"
|
|
47
|
+
tenantName={activeTenantLabel}
|
|
48
|
+
environment="local"
|
|
49
|
+
showSettings={false}
|
|
50
|
+
>
|
|
51
|
+
{#snippet docs()}
|
|
52
|
+
{#if data.session.selectedTenantSlug}
|
|
53
|
+
<p class="tenant-selection">
|
|
54
|
+
Selected URL tenant: <strong>{data.session.selectedTenantSlug}</strong>
|
|
55
|
+
</p>
|
|
56
|
+
{/if}
|
|
57
|
+
<a href="/settings">Shell settings</a>
|
|
58
|
+
{/snippet}
|
|
59
|
+
</AppScopePanel>
|
|
68
60
|
{/snippet}
|
|
69
|
-
</AppScopePanel>
|
|
70
|
-
{/snippet}
|
|
71
61
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{#snippet systemBar()}
|
|
77
|
-
<div class="system-bar">
|
|
78
|
-
<SystemStatusChips chips={statusChips} />
|
|
79
|
-
<a class="system-bar__settings" href="/settings">Settings</a>
|
|
80
|
-
</div>
|
|
81
|
-
{/snippet}
|
|
62
|
+
{#snippet tenantPanel()}
|
|
63
|
+
<TenantNav items={nav} {currentHref} />
|
|
64
|
+
{/snippet}
|
|
82
65
|
|
|
83
|
-
|
|
84
|
-
</AdminShell>
|
|
85
|
-
</ThemeProvider>
|
|
66
|
+
{@render children()}
|
|
67
|
+
</AdminShell>
|
|
68
|
+
</ThemeProvider>
|
|
69
|
+
</Provider>
|
|
86
70
|
|
|
87
71
|
<style>
|
|
72
|
+
:global(*),
|
|
73
|
+
:global(*::before),
|
|
74
|
+
:global(*::after) {
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
:global(html),
|
|
88
79
|
:global(body) {
|
|
80
|
+
min-height: 100%;
|
|
89
81
|
margin: 0;
|
|
90
82
|
}
|
|
91
83
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
:global(body) {
|
|
85
|
+
background: var(--smrt-color-background);
|
|
86
|
+
color: var(--smrt-color-on-background);
|
|
87
|
+
font-family: var(--smrt-typography-body-font-family, Inter, system-ui, sans-serif);
|
|
95
88
|
}
|
|
96
89
|
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
color: var(--smrt-color-
|
|
100
|
-
text-decoration: none;
|
|
90
|
+
.tenant-selection {
|
|
91
|
+
margin: 0 0 var(--smrt-spacing-2);
|
|
92
|
+
color: var(--smrt-color-on-surface-variant);
|
|
101
93
|
}
|
|
102
94
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
align-items: center;
|
|
106
|
-
justify-content: space-between;
|
|
107
|
-
gap: var(--smrt-spacing-3);
|
|
108
|
-
inline-size: 100%;
|
|
95
|
+
a {
|
|
96
|
+
color: var(--smrt-color-primary);
|
|
109
97
|
}
|
|
110
98
|
</style>
|