@tristan2828/ui-foundation 4.4.0 → 4.5.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/dist/components/app/app-shell-version.d.ts +15 -0
- package/dist/components/app/app-shell-version.js +26 -0
- package/dist/components/app/app-shell.d.ts +10 -1
- package/dist/components/app/app-shell.js +18 -8
- package/dist/index.d.ts +1 -1
- package/package.json +10 -1
- package/vite/index.d.ts +14 -0
- package/vite/index.js +56 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type AppShellVersion = {
|
|
2
|
+
/** The full or short commit hash. Empty or missing: nothing is shown. */
|
|
3
|
+
commit?: string;
|
|
4
|
+
/** The PR the build came from; links there instead of to the commit. */
|
|
5
|
+
pr?: string | number;
|
|
6
|
+
/** The repository's web address, e.g. `https://github.com/me/app`. Missing: the commit shows unlinked. */
|
|
7
|
+
repoUrl?: string;
|
|
8
|
+
};
|
|
9
|
+
/** What the entry says, or null when there's no commit (development). */
|
|
10
|
+
export declare function versionLabel({ commit, pr }: AppShellVersion): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* The PR's page, else the commit's, or null when there's no address to
|
|
13
|
+
* trust: only an http(s) repository makes a link.
|
|
14
|
+
*/
|
|
15
|
+
export declare function versionHref({ commit, pr, repoUrl }: AppShellVersion): string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// AppShell's version entry: which build is live, and where it came from.
|
|
2
|
+
// The values are set at build time by `@tristan2828/ui-foundation/vite`.
|
|
3
|
+
function prNumber(pr) {
|
|
4
|
+
const value = String(pr ?? '').trim();
|
|
5
|
+
return /^\d+$/.test(value) ? value : null;
|
|
6
|
+
}
|
|
7
|
+
/** What the entry says, or null when there's no commit (development). */
|
|
8
|
+
export function versionLabel({ commit, pr }) {
|
|
9
|
+
const hash = commit?.trim();
|
|
10
|
+
if (!hash)
|
|
11
|
+
return null;
|
|
12
|
+
const number = prNumber(pr);
|
|
13
|
+
return number ? `Version ${hash.slice(0, 7)} · PR #${number}` : `Version ${hash.slice(0, 7)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The PR's page, else the commit's, or null when there's no address to
|
|
17
|
+
* trust: only an http(s) repository makes a link.
|
|
18
|
+
*/
|
|
19
|
+
export function versionHref({ commit, pr, repoUrl }) {
|
|
20
|
+
const hash = commit?.trim();
|
|
21
|
+
const repo = repoUrl?.trim().replace(/\/+$/, '');
|
|
22
|
+
if (!hash || !repo || !/^https?:\/\/[^/]/i.test(repo))
|
|
23
|
+
return null;
|
|
24
|
+
const number = prNumber(pr);
|
|
25
|
+
return number ? `${repo}/pull/${number}` : `${repo}/commit/${encodeURIComponent(hash)}`;
|
|
26
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
2
|
import { type NavEntry } from './app-shell-nav.js';
|
|
3
|
+
import { type AppShellVersion } from './app-shell-version.js';
|
|
3
4
|
export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
5
|
+
export type { AppShellVersion } from './app-shell-version.js';
|
|
4
6
|
export type AppShellProps = {
|
|
5
7
|
/** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
|
|
6
8
|
title: string;
|
|
@@ -24,5 +26,12 @@ export type AppShellProps = {
|
|
|
24
26
|
* primitive writes a cookie, read back here). A wide table wants false.
|
|
25
27
|
*/
|
|
26
28
|
defaultSidebarOpen?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Which build is live, as a short commit in the sidebar footer linking to
|
|
31
|
+
* its PR (or to the commit when there's no PR). Nothing is shown without
|
|
32
|
+
* a commit, so development shows nothing. The values come from
|
|
33
|
+
* `@tristan2828/ui-foundation/vite` (`import.meta.env.VITE_BUILD_*`).
|
|
34
|
+
*/
|
|
35
|
+
version?: AppShellVersion;
|
|
27
36
|
};
|
|
28
|
-
export declare function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen }: AppShellProps): import("react").JSX.Element;
|
|
37
|
+
export declare function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen, version }: AppShellProps): import("react").JSX.Element;
|
|
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
|
|
|
5
5
|
import { Link, Navigate, Outlet, useLocation } from 'react-router';
|
|
6
6
|
import { useAuth } from '../../auth/use-auth.js';
|
|
7
7
|
import { activeNavTo, navGroups } from './app-shell-nav.js';
|
|
8
|
+
import { versionHref, versionLabel } from './app-shell-version.js';
|
|
8
9
|
import { ErrorState } from './error-state.js';
|
|
9
10
|
import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
|
|
10
11
|
import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
|
|
@@ -37,10 +38,19 @@ function ThemeToggle() {
|
|
|
37
38
|
const isDark = resolvedTheme === 'dark';
|
|
38
39
|
return (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Toggle dark mode", onClick: () => setTheme(isDark ? 'light' : 'dark'), children: isDark ? _jsx(SunIcon, {}) : _jsx(MoonIcon, {}) }));
|
|
39
40
|
}
|
|
41
|
+
// Hidden on the icon rail, like the user's name beside it.
|
|
42
|
+
function VersionEntry({ version }) {
|
|
43
|
+
const label = versionLabel(version);
|
|
44
|
+
if (!label)
|
|
45
|
+
return null;
|
|
46
|
+
const href = versionHref(version);
|
|
47
|
+
const className = 'block truncate px-2 type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden';
|
|
48
|
+
return href ? (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", className: `${className} hover:underline`, children: label })) : (_jsx("span", { className: className, children: label }));
|
|
49
|
+
}
|
|
40
50
|
function AppShellSkeleton() {
|
|
41
51
|
return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
|
|
42
52
|
}
|
|
43
|
-
export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }) {
|
|
53
|
+
export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true, version }) {
|
|
44
54
|
const { user, status, error, retry, logout } = useAuth();
|
|
45
55
|
const location = useLocation();
|
|
46
56
|
const reporter = useErrorReporter();
|
|
@@ -75,11 +85,11 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
|
|
|
75
85
|
// one should be (activeNavTo).
|
|
76
86
|
const isActive = to === activeTo;
|
|
77
87
|
return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
|
|
78
|
-
}) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
}) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
|
|
89
|
+
setIsLoggingOut(true);
|
|
90
|
+
// A failed request still ends the session on screen
|
|
91
|
+
// (AuthProvider), so this stays a deliberate logout: a
|
|
92
|
+
// clean /login, never the previous user's page as `from`.
|
|
93
|
+
logout().catch(() => { });
|
|
94
|
+
}, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(Toaster, { position: "top-center" })] }));
|
|
85
95
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
1
|
+
export { AppShell, type AppShellProps, type AppShellVersion, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
2
2
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
3
3
|
export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
|
|
4
4
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dist",
|
|
17
17
|
"styles",
|
|
18
18
|
"eslint",
|
|
19
|
+
"vite",
|
|
19
20
|
"bin",
|
|
20
21
|
"conventions",
|
|
21
22
|
"openapi"
|
|
@@ -48,6 +49,10 @@
|
|
|
48
49
|
"types": "./eslint/index.d.ts",
|
|
49
50
|
"default": "./eslint/index.js"
|
|
50
51
|
},
|
|
52
|
+
"./vite": {
|
|
53
|
+
"types": "./vite/index.d.ts",
|
|
54
|
+
"default": "./vite/index.js"
|
|
55
|
+
},
|
|
51
56
|
"./styles.css": {
|
|
52
57
|
"style": "./styles/index.css",
|
|
53
58
|
"default": "./styles/index.css"
|
|
@@ -88,6 +93,7 @@
|
|
|
88
93
|
"sonner": "^2.0.8",
|
|
89
94
|
"tailwindcss": "^4.3.3",
|
|
90
95
|
"typescript-eslint": "^8.70.0",
|
|
96
|
+
"vite": "^8.3.0",
|
|
91
97
|
"zod": "^4.6.5"
|
|
92
98
|
},
|
|
93
99
|
"peerDependenciesMeta": {
|
|
@@ -123,6 +129,9 @@
|
|
|
123
129
|
},
|
|
124
130
|
"typescript-eslint": {
|
|
125
131
|
"optional": true
|
|
132
|
+
},
|
|
133
|
+
"vite": {
|
|
134
|
+
"optional": true
|
|
126
135
|
}
|
|
127
136
|
},
|
|
128
137
|
"dependencies": {
|
package/vite/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
|
|
3
|
+
export type BuildInfoOptions = {
|
|
4
|
+
/** The app's repository, e.g. `https://github.com/me/app`. `BUILD_REPO_URL` wins over it. */
|
|
5
|
+
repoUrl?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* On `vite build`, writes `dist/build-info.json` (`api`: "real" or "mock",
|
|
10
|
+
* `commit`, `pr`, `builtAt`) and sets `import.meta.env.VITE_BUILD_COMMIT`,
|
|
11
|
+
* `VITE_BUILD_PR` and `VITE_BUILD_REPO_URL` from `BUILD_COMMIT`,
|
|
12
|
+
* `BUILD_PR` and `BUILD_REPO_URL`.
|
|
13
|
+
*/
|
|
14
|
+
export default function buildInfo(options?: BuildInfoOptions): Plugin
|
package/vite/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// `@tristan2828/ui-foundation/vite` — what a production build says about
|
|
2
|
+
// itself. An app's vite.config.ts:
|
|
3
|
+
//
|
|
4
|
+
// import buildInfo from '@tristan2828/ui-foundation/vite'
|
|
5
|
+
// plugins: [react(), tailwindcss(), buildInfo({ repoUrl: 'https://github.com/me/app' })]
|
|
6
|
+
//
|
|
7
|
+
// On `vite build` it:
|
|
8
|
+
// - writes dist/build-info.json: which API the bundle calls ("real", or
|
|
9
|
+
// "mock" for MSW), and the commit and PR it was built from. The reference
|
|
10
|
+
// backend refuses to serve a mock bundle with APP_ENV=production.
|
|
11
|
+
// - sets import.meta.env.VITE_BUILD_COMMIT, VITE_BUILD_PR and
|
|
12
|
+
// VITE_BUILD_REPO_URL from the environment's BUILD_COMMIT, BUILD_PR and
|
|
13
|
+
// BUILD_REPO_URL (the repoUrl option when that's unset), for AppShell's
|
|
14
|
+
// `version`. Unset, they're empty strings and nothing is shown.
|
|
15
|
+
//
|
|
16
|
+
// Build only: in development the three are undefined. Plain JavaScript, like
|
|
17
|
+
// the eslint entry: Vite loads the config before any TypeScript tooling runs.
|
|
18
|
+
|
|
19
|
+
/** @param {string | undefined} value */
|
|
20
|
+
function clean(value) {
|
|
21
|
+
const trimmed = value?.trim()
|
|
22
|
+
return trimmed ? trimmed : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {{ repoUrl?: string }} [options] */
|
|
26
|
+
export default function buildInfo(options = {}) {
|
|
27
|
+
const commit = clean(process.env.BUILD_COMMIT)
|
|
28
|
+
const pr = clean(process.env.BUILD_PR)
|
|
29
|
+
const repoUrl = clean(process.env.BUILD_REPO_URL) ?? clean(options.repoUrl)
|
|
30
|
+
let api = 'mock'
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
name: 'ui-foundation:build-info',
|
|
34
|
+
apply: 'build',
|
|
35
|
+
config() {
|
|
36
|
+
return {
|
|
37
|
+
define: {
|
|
38
|
+
'import.meta.env.VITE_BUILD_COMMIT': JSON.stringify(commit ?? ''),
|
|
39
|
+
'import.meta.env.VITE_BUILD_PR': JSON.stringify(pr ?? ''),
|
|
40
|
+
'import.meta.env.VITE_BUILD_REPO_URL': JSON.stringify(repoUrl ?? ''),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
configResolved(config) {
|
|
45
|
+
// The same test as the app's main.tsx: anything but "real" ships MSW.
|
|
46
|
+
api = config.env.VITE_API === 'real' ? 'real' : 'mock'
|
|
47
|
+
},
|
|
48
|
+
generateBundle() {
|
|
49
|
+
this.emitFile({
|
|
50
|
+
type: 'asset',
|
|
51
|
+
fileName: 'build-info.json',
|
|
52
|
+
source: `${JSON.stringify({ api, commit, pr, builtAt: new Date().toISOString() }, null, 2)}\n`,
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|