@olwiba/ui 0.2.4 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/ui",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -64,7 +64,7 @@
64
64
  "devDependencies": {
65
65
  "@content-collections/mdx": "^0.2.2",
66
66
  "@olwiba/cn": "0.1.26",
67
- "@olwiba/docs": "0.1.35",
67
+ "@olwiba/docs": "0.1.38",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -110,6 +110,15 @@ export interface AppShellProps {
110
110
  sidebarPosition?: 'viewport' | 'contained';
111
111
  /** Which side the sidebar sits on. @default "left" */
112
112
  side?: 'left' | 'right';
113
+ /**
114
+ * Class applied to the content wrapper around `children`, between the
115
+ * header and the page content. Every consuming page otherwise has to
116
+ * remember its own padding — some do, some don't, and the ones that don't
117
+ * end up flush against the sidebar/header chrome. Override to `''` for a
118
+ * genuinely edge-to-edge page (a full-width table, a map/canvas).
119
+ * @default "p-6"
120
+ */
121
+ contentClassName?: string;
113
122
  }
114
123
 
115
124
  // ─── Internal sub-components ──────────────────────────────────────────────────
@@ -377,6 +386,7 @@ export function AppShell({
377
386
  collapsible = 'icon',
378
387
  sidebarPosition = 'viewport',
379
388
  side = 'left',
389
+ contentClassName = 'p-6',
380
390
  children,
381
391
  }: AppShellProps = {}) {
382
392
  const isContained = sidebarPosition === 'contained';
@@ -398,7 +408,7 @@ export function AppShell({
398
408
  />
399
409
  <SidebarInset className={isContained ? undefined : 'overflow-y-auto'}>
400
410
  <ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
401
- {children}
411
+ <div className={contentClassName}>{children}</div>
402
412
  </SidebarInset>
403
413
  </SidebarProvider>
404
414
  );
@@ -0,0 +1,130 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { RefreshCw, Sparkles } from 'lucide-react';
5
+ import { Button, Card, cn } from '@olwiba/cn';
6
+
7
+ export interface UpdateBannerProps {
8
+ /** Version baked into the running client bundle (e.g. a git short SHA embedded at build time). */
9
+ currentVersion: string;
10
+ /**
11
+ * Fetches the currently deployed version from the server. The host app wires
12
+ * its own endpoint — the component stays app-agnostic.
13
+ */
14
+ fetchVersion: () => Promise<{ version: string }>;
15
+ /** Poll interval in ms. */
16
+ intervalMs?: number;
17
+ /** Force the banner to show (demos/testing). */
18
+ forceShow?: boolean;
19
+ /** Called on refresh click; defaults to a full page reload. Use it to hook a celebration first. */
20
+ onRefresh?: () => void;
21
+ message?: React.ReactNode;
22
+ buttonLabel?: string;
23
+ className?: string;
24
+ }
25
+
26
+ export function UpdateBanner({
27
+ currentVersion,
28
+ fetchVersion,
29
+ intervalMs = 5 * 60 * 1000,
30
+ forceShow = false,
31
+ onRefresh,
32
+ message = (
33
+ <>
34
+ <span className="font-semibold">App update available!</span> Refresh to get the latest features
35
+ and improvements.
36
+ </>
37
+ ),
38
+ buttonLabel = 'Refresh now',
39
+ className,
40
+ }: UpdateBannerProps) {
41
+ const [show, setShow] = React.useState(forceShow);
42
+ const [entered, setEntered] = React.useState(false);
43
+ const [refreshing, setRefreshing] = React.useState(false);
44
+
45
+ React.useEffect(() => {
46
+ if (forceShow) {
47
+ setShow(true);
48
+ return;
49
+ }
50
+ setShow(false);
51
+
52
+ let cancelled = false;
53
+
54
+ const check = async () => {
55
+ try {
56
+ const server = await fetchVersion();
57
+ if (!cancelled && server.version && server.version !== currentVersion) {
58
+ setShow(true);
59
+ }
60
+ } catch {
61
+ // Version checks are best-effort; stay quiet on failure.
62
+ }
63
+ };
64
+
65
+ void check();
66
+ const id = setInterval(check, intervalMs);
67
+ return () => {
68
+ cancelled = true;
69
+ clearInterval(id);
70
+ };
71
+ }, [currentVersion, fetchVersion, forceShow, intervalMs]);
72
+
73
+ // Two-frame mount so the enter transition actually plays.
74
+ React.useEffect(() => {
75
+ if (!show) {
76
+ setEntered(false);
77
+ return;
78
+ }
79
+ const id = requestAnimationFrame(() => setEntered(true));
80
+ return () => cancelAnimationFrame(id);
81
+ }, [show]);
82
+
83
+ const handleRefresh = () => {
84
+ setRefreshing(true);
85
+ setTimeout(() => {
86
+ if (onRefresh) {
87
+ onRefresh();
88
+ setRefreshing(false);
89
+ if (forceShow) setShow(false);
90
+ } else {
91
+ window.location.reload();
92
+ }
93
+ }, 400);
94
+ };
95
+
96
+ if (!show) return null;
97
+
98
+ return (
99
+ <div className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4">
100
+ <div
101
+ className={cn(
102
+ 'pointer-events-auto transition-all duration-500 ease-out',
103
+ entered ? 'translate-y-0 opacity-100' : '-translate-y-24 opacity-0',
104
+ )}
105
+ >
106
+ <Card
107
+ className={cn(
108
+ 'max-w-3xl border-amber-300/60 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-950',
109
+ className,
110
+ )}
111
+ >
112
+ <div className="flex items-center justify-between gap-6 p-4">
113
+ <div className="flex items-center gap-3">
114
+ <Sparkles className="size-5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden="true" />
115
+ <p className="text-sm text-amber-800 dark:text-amber-200">{message}</p>
116
+ </div>
117
+ <Button
118
+ onClick={handleRefresh}
119
+ disabled={refreshing}
120
+ size="sm"
121
+ className="shrink-0 bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700"
122
+ >
123
+ {refreshing ? <RefreshCw className="size-4 animate-spin" /> : buttonLabel}
124
+ </Button>
125
+ </div>
126
+ </Card>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export {
76
76
  type UpgradeComparisonRow,
77
77
  } from './app/UpgradePrompt';
78
78
 
79
+ export { UpdateBanner, type UpdateBannerProps } from './app/UpdateBanner';
80
+
79
81
  // ─── Marketing — page sections ────────────────────────────────────────────────
80
82
  export { SectionTitle, type SectionTitleProps } from './marketing/SectionTitle';
81
83
  export { marketingSectionSpacing, type MarketingSectionSpacing } from './marketing/section-spacing';