@viibestack/ui 0.2.0 → 0.6.1

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@viibestack/ui",
3
- "version": "0.2.0",
4
- "description": "Dependency-free React UI kit -- icons plus core components (Button, Card, Input, Modal, etc.), styled with plain Tailwind utility classes, no runtime dependency beyond react/react-dom.",
3
+ "version": "0.6.1",
4
+ "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), a real per-app data client (listRows/upsertRow/deleteRow/reportError), and a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser) backed by ViibeStack's own platform-managed data store.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -22,5 +22,8 @@
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
- "license": "MIT"
25
+ "license": "MIT",
26
+ "dependencies": {
27
+ "@modelcontextprotocol/ext-apps": "^1.7.4"
28
+ }
26
29
  }
package/src/auth.ts ADDED
@@ -0,0 +1,332 @@
1
+ // Client for the app-auth feature -- real per-app end-user sign-up/login
2
+ // (see db/migrations/auth/012_app_end_users.sql). Framework-agnostic like
3
+ // data.ts, and reuses the exact same resolveAppId() pattern. Unlike
4
+ // data.ts's background sync (which fails silently -- a lost write shouldn't
5
+ // crash the app), these are interactive flows a real person is waiting on,
6
+ // so failures are surfaced as a real error message instead of swallowed.
7
+ //
8
+ // Session refresh happens automatically inside getCurrentUser() -- generated
9
+ // app code never has to implement a refresh loop itself, the same way it
10
+ // never has to think about resolveAppId() caching.
11
+
12
+ export interface AppUser {
13
+ id: string;
14
+ email: string;
15
+ }
16
+
17
+ export type AuthActionResult = { ok: true; user: AppUser } | { ok: false; error: string };
18
+ export type ActionResult = { ok: true } | { ok: false; error: string };
19
+
20
+ let appIdPromise: Promise<string | null> | null = null;
21
+
22
+ function resolveAppId(): Promise<string | null> {
23
+ if (!appIdPromise) {
24
+ appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
25
+ .then((res) => (res.ok ? res.json() : null))
26
+ .then((data: { app_id?: string } | null) => data?.app_id ?? null)
27
+ .catch(() => null);
28
+ }
29
+ return appIdPromise;
30
+ }
31
+
32
+ interface StoredSession {
33
+ jwt: string;
34
+ sessionId: string;
35
+ user: AppUser;
36
+ }
37
+
38
+ const STORAGE_KEY = "viibestack_app_auth";
39
+
40
+ const ACCESS_GATE_STORAGE_KEY = "viibestack_access_gate";
41
+
42
+ // Read-only peek at the current auth credential for other library modules
43
+ // that need to authenticate a request but shouldn't own session lifecycle
44
+ // themselves (see data.ts's listRows/upsertRow/deleteRow, which attach this
45
+ // as a bearer token). Checks a real_accounts session first, then falls back
46
+ // to a shared_password gate token (see verifyAccessPassword below) -- an
47
+ // app is only ever in one auth_mode at a time, so at most one of these is
48
+ // ever actually populated, but checking both here means data.ts doesn't
49
+ // need to know or care which mode this app is in. Doesn't refresh an
50
+ // about-to-expire token the way getCurrentUser() does -- callers here are
51
+ // best-effort, fire-and-forget writes, not something worth adding a
52
+ // network round-trip to on every call.
53
+ export function getStoredAuthToken(): string | null {
54
+ return loadStoredSession()?.jwt ?? loadStoredGateToken();
55
+ }
56
+
57
+ function loadStoredSession(): StoredSession | null {
58
+ try {
59
+ const raw = localStorage.getItem(STORAGE_KEY);
60
+ return raw ? (JSON.parse(raw) as StoredSession) : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function loadStoredGateToken(): string | null {
67
+ try {
68
+ return localStorage.getItem(ACCESS_GATE_STORAGE_KEY);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ // shared_password apps (auth_mode = 'real_accounts' is the other, separate
75
+ // option -- see signUp/logIn above): a single password shared by everyone
76
+ // who should have access, checked server-side (services/portal's
77
+ // /access-gate/verify) rather than trusting the app's own client code to
78
+ // enforce it. Call this from the app's password-gate screen; on success the
79
+ // token is stored and every subsequent data.ts call authenticates with it
80
+ // automatically via getStoredAuthToken() above -- build a screen that calls
81
+ // this once and then renders the app, same shape as a signIn() call.
82
+ export async function verifyAccessPassword(password: string): Promise<ActionResult> {
83
+ const appId = await resolveAppId();
84
+ if (!appId) return { ok: false, error: "couldn't determine this app's id" };
85
+ try {
86
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/access-gate/verify`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ password }),
90
+ });
91
+ const body = (await res.json().catch(() => ({}))) as { token?: string; error?: string };
92
+ if (!res.ok || !body.token) return { ok: false, error: body.error ?? "incorrect password" };
93
+ try {
94
+ localStorage.setItem(ACCESS_GATE_STORAGE_KEY, body.token);
95
+ } catch {
96
+ // best-effort -- private browsing / storage-disabled shouldn't crash the app
97
+ }
98
+ return { ok: true };
99
+ } catch {
100
+ return { ok: false, error: "network error" };
101
+ }
102
+ }
103
+
104
+ // Whether this browser already has a stored gate token -- call on page load
105
+ // (like getCurrentUser()) to decide whether to show the password screen or
106
+ // the app itself. Doesn't verify the token is still valid server-side
107
+ // (that happens naturally on the first real data.ts call); this is just a
108
+ // fast, synchronous "has this device unlocked before" check.
109
+ export function hasVerifiedAccessPassword(): boolean {
110
+ return loadStoredGateToken() !== null;
111
+ }
112
+
113
+ function saveStoredSession(session: StoredSession | null): void {
114
+ try {
115
+ if (session) localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
116
+ else localStorage.removeItem(STORAGE_KEY);
117
+ } catch {
118
+ // best-effort -- private browsing / storage-disabled shouldn't crash the app
119
+ }
120
+ }
121
+
122
+ // Reads the JWT's own `exp` claim without verifying the signature (that's
123
+ // services/auth's job) -- just enough to know when a proactive refresh is
124
+ // worthwhile, same purpose as the portal frontend's own pre-expiry refresh.
125
+ function decodeJwtExpiry(jwt: string): number | null {
126
+ try {
127
+ const payload = JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
128
+ return typeof payload.exp === "number" ? payload.exp : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ async function callAppAuth<T>(path: string, init?: RequestInit): Promise<{ ok: true; body: T } | { ok: false; error: string }> {
135
+ const appId = await resolveAppId();
136
+ if (!appId) return { ok: false, error: "couldn't determine this app's id" };
137
+ try {
138
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/app-auth${path}`, {
139
+ ...init,
140
+ headers: { "Content-Type": "application/json", ...init?.headers },
141
+ });
142
+ const body = (await res.json().catch(() => ({}))) as T & { error?: string };
143
+ if (!res.ok) return { ok: false, error: body.error ?? "request failed" };
144
+ return { ok: true, body };
145
+ } catch {
146
+ return { ok: false, error: "network error" };
147
+ }
148
+ }
149
+
150
+ type SessionResponse = { jwt: string; sessionId: string; user: AppUser };
151
+
152
+ export async function signUp(email: string, password: string): Promise<AuthActionResult> {
153
+ const result = await callAppAuth<SessionResponse>("/signup", { method: "POST", body: JSON.stringify({ email, password }) });
154
+ if (!result.ok) return { ok: false, error: result.error };
155
+ saveStoredSession(result.body);
156
+ return { ok: true, user: result.body.user };
157
+ }
158
+
159
+ export async function logIn(email: string, password: string): Promise<AuthActionResult> {
160
+ const result = await callAppAuth<SessionResponse>("/login", { method: "POST", body: JSON.stringify({ email, password }) });
161
+ if (!result.ok) return { ok: false, error: result.error };
162
+ saveStoredSession(result.body);
163
+ return { ok: true, user: result.body.user };
164
+ }
165
+
166
+ export async function logOut(): Promise<void> {
167
+ const stored = loadStoredSession();
168
+ saveStoredSession(null);
169
+ if (!stored) return;
170
+ await callAppAuth("/logout", { method: "POST", body: JSON.stringify({ session_id: stored.sessionId }) });
171
+ }
172
+
173
+ // The one function generated app code is expected to call on every page
174
+ // load to find out "is anyone logged in" -- returns null if there's no
175
+ // session, or if the session has expired/been revoked server-side (in
176
+ // which case the stale local copy is cleared automatically).
177
+ export async function getCurrentUser(): Promise<AppUser | null> {
178
+ const stored = loadStoredSession();
179
+ if (!stored) return null;
180
+
181
+ const exp = decodeJwtExpiry(stored.jwt);
182
+ const needsRefresh = exp == null || exp - Math.floor(Date.now() / 1000) < 120;
183
+ if (!needsRefresh) return stored.user;
184
+
185
+ const result = await callAppAuth<SessionResponse>("/session/refresh", {
186
+ method: "POST",
187
+ body: JSON.stringify({ session_id: stored.sessionId }),
188
+ });
189
+ if (!result.ok) {
190
+ saveStoredSession(null);
191
+ return null;
192
+ }
193
+ saveStoredSession(result.body);
194
+ return result.body.user;
195
+ }
196
+
197
+ export async function requestPasswordReset(email: string): Promise<ActionResult> {
198
+ const result = await callAppAuth<{ ok: boolean }>("/password-reset/request", { method: "POST", body: JSON.stringify({ email }) });
199
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
200
+ }
201
+
202
+ export async function confirmPasswordReset(token: string, newPassword: string): Promise<ActionResult> {
203
+ const result = await callAppAuth<{ ok: boolean }>("/password-reset/confirm", {
204
+ method: "POST",
205
+ body: JSON.stringify({ token, new_password: newPassword }),
206
+ });
207
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
208
+ }
209
+
210
+ // "Sign in with Google" -- a full-page redirect (not a fetch/popup, same
211
+ // convention as the portal's own Google login), so this hits
212
+ // services/auth directly rather than going through the app-auth proxy
213
+ // above. Async because it needs the resolved app_id first; render the
214
+ // resulting URL as a plain <a href>, e.g.:
215
+ // const [url, setUrl] = useState<string | null>(null);
216
+ // useEffect(() => { googleSignInUrl().then(setUrl); }, []);
217
+ // {url && <a href={url}>Sign in with Google</a>}
218
+ const AUTH_URL = "https://sideblend-auth.sideblend.workers.dev";
219
+
220
+ export async function googleSignInUrl(): Promise<string | null> {
221
+ const appId = await resolveAppId();
222
+ if (!appId) return null;
223
+ const params = new URLSearchParams({ app_id: appId, redirect_uri: window.location.origin + window.location.pathname });
224
+ return `${AUTH_URL}/v1/auth/oauth/google/app-start?${params.toString()}`;
225
+ }
226
+
227
+ // Call once on page load (e.g. in a top-level useEffect) to pick up the
228
+ // tokens Google sign-in redirects back with. Returns null on an ordinary
229
+ // page load with nothing to consume; strips the query params either way so
230
+ // a refresh doesn't re-process them. Store methods (signUp/logIn) already
231
+ // call the internal save function directly -- this is the one path that
232
+ // has to parse it back out of a URL instead.
233
+ export function consumeGoogleRedirect(): AuthActionResult | null {
234
+ const params = new URLSearchParams(window.location.search);
235
+ const error = params.get("error");
236
+ const jwt = params.get("jwt");
237
+ const sessionId = params.get("session_id");
238
+ const userId = params.get("user_id");
239
+ const email = params.get("email");
240
+ if (!error && !jwt) return null; // nothing to consume -- an ordinary page load
241
+
242
+ window.history.replaceState({}, "", window.location.pathname);
243
+ if (error) return { ok: false, error };
244
+ if (!jwt || !sessionId || !userId || !email) return { ok: false, error: "incomplete sign-in response" };
245
+ const user = { id: userId, email };
246
+ saveStoredSession({ jwt, sessionId, user });
247
+ return { ok: true, user };
248
+ }
249
+
250
+ // Monetization (061_monetization.sql) -- only meaningful once the app
251
+ // OWNER has turned monetization on and set a price from the portal's
252
+ // Access tab; there's no way for generated app code to enable this itself.
253
+ // Both functions require a signed-in end-user (call signUp/logIn first) --
254
+ // they reuse the SAME stored session as the rest of this file.
255
+ export interface MonetizationStatus {
256
+ billing_interval: "monthly" | "annual";
257
+ status: string; // mirrors Stripe verbatim: trialing/active/past_due/canceled/...
258
+ trial_end: string | null;
259
+ seats: number; // 1 unless the app owner set pricing_model to per-seat
260
+ }
261
+
262
+ // Redirects the browser to Stripe Checkout to start (or restart) the
263
+ // current end-user's subscription. Resolves once the redirect has been
264
+ // issued (ok: true) -- there's no "after checkout" callback here; check
265
+ // getMonetizationStatus() again once the user is back (Stripe redirects to
266
+ // this app's own URL with ?billing=success/cancelled). `seats` only matters
267
+ // when the app owner set per-seat pricing (ignored server-side otherwise) --
268
+ // omit it for flat-priced apps.
269
+ export async function createMonetizationCheckout(interval: "monthly" | "annual", seats?: number): Promise<ActionResult> {
270
+ const stored = loadStoredSession();
271
+ if (!stored) return { ok: false, error: "not signed in" };
272
+ const appId = await resolveAppId();
273
+ if (!appId) return { ok: false, error: "couldn't determine this app's id" };
274
+ try {
275
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/checkout`, {
276
+ method: "POST",
277
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${stored.jwt}` },
278
+ body: JSON.stringify({ billing_interval: interval, seats }),
279
+ });
280
+ const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
281
+ if (!res.ok || !body.url) return { ok: false, error: body.error ?? "failed to start checkout" };
282
+ window.location.href = body.url;
283
+ return { ok: true };
284
+ } catch {
285
+ return { ok: false, error: "network error" };
286
+ }
287
+ }
288
+
289
+ // The current end-user's own subscription/trial state -- build an
290
+ // "Upgrade" prompt or gate a feature against this. Returns null if they
291
+ // have no subscription (never checked out, or this app isn't monetized).
292
+ export async function getMonetizationStatus(): Promise<MonetizationStatus | null> {
293
+ const stored = loadStoredSession();
294
+ if (!stored) return null;
295
+ const appId = await resolveAppId();
296
+ if (!appId) return null;
297
+ try {
298
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/status`, {
299
+ headers: { Authorization: `Bearer ${stored.jwt}` },
300
+ });
301
+ if (!res.ok) return null;
302
+ const body = (await res.json()) as { subscription: MonetizationStatus | null };
303
+ return body.subscription;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ // The standard "need help with this error?" report -- distinct from the
310
+ // automatic window.onerror/unhandledrejection capture every generated app
311
+ // already wires up (that one posts directly to .../errors via raw fetch per
312
+ // integrationRequirements(), no packages/ui helper involved). This one is
313
+ // for the explicit prompt: when an error surfaces, show a small toast/banner
314
+ // asking the end-user if they want to report it, and call this if they say
315
+ // yes. No sign-in required -- reporter_email is optional context, not auth.
316
+ // Server-side this always emails the app owner (072_app_error_log_workflow.sql's
317
+ // alerted_at still dedupes a recurring message within the same hour).
318
+ export async function reportError(message: string, details?: Record<string, unknown>, reporterEmail?: string): Promise<ActionResult> {
319
+ const appId = await resolveAppId();
320
+ if (!appId) return { ok: false, error: "couldn't determine this app's id" };
321
+ try {
322
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/errors/report`, {
323
+ method: "POST",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify({ message, details, reporter_email: reporterEmail }),
326
+ });
327
+ if (!res.ok) return { ok: false, error: "failed to report" };
328
+ return { ok: true };
329
+ } catch {
330
+ return { ok: false, error: "network error" };
331
+ }
332
+ }
package/src/data.ts ADDED
@@ -0,0 +1,122 @@
1
+ // Client for a 'platform_managed' app's real backend data store -- the
2
+ // per-app JSONB mirror at platform.app_data_tables/app_data_rows, now
3
+ // readable/writable by the app itself (see services/portal/src/index.ts's
4
+ // GET/DELETE .../data-public/:table routes), not just a write-only sync
5
+ // target. Framework-agnostic (plain async functions, no React) so any
6
+ // generated or uploaded app can use it regardless of how it manages state.
7
+ //
8
+ // Data here is shared with anyone who has the app's URL, UNLESS the app has
9
+ // real per-user accounts turned on (see auth.ts) -- in that case the server
10
+ // requires a signed-in session for every call below (services/portal's
11
+ // requireDataAccess, gated on app_registry.auth_mode = 'real_accounts'), so
12
+ // every function here attaches the current session's token when one exists.
13
+ // For an app with no real accounts this is just shared data, not
14
+ // access-controlled per account -- fine for a household/team app that wants
15
+ // everyone to see the same thing.
16
+
17
+ import { getStoredAuthToken } from "./auth";
18
+
19
+ function authHeaders(): Record<string, string> {
20
+ const token = getStoredAuthToken();
21
+ return token ? { Authorization: `Bearer ${token}` } : {};
22
+ }
23
+
24
+ let appIdPromise: Promise<string | null> | null = null;
25
+
26
+ function resolveAppId(): Promise<string | null> {
27
+ if (!appIdPromise) {
28
+ appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
29
+ .then((res) => (res.ok ? res.json() : null))
30
+ .then((data: { app_id?: string } | null) => data?.app_id ?? null)
31
+ .catch(() => null);
32
+ }
33
+ return appIdPromise;
34
+ }
35
+
36
+ export interface RowsPage<T> {
37
+ rows: T[];
38
+ nextCursor: string | null;
39
+ }
40
+
41
+ // One page of a table, server-paginated (keyset on updated_at+id, newest
42
+ // first) -- for a table expected to grow into the thousands+ (a CRM's
43
+ // contacts, a helpdesk's tickets), build a real "load more"/infinite-scroll
44
+ // UI around this instead of assuming listRows() below hands back
45
+ // everything at once.
46
+ export async function listRowsPage<T extends { id: string }>(table: string, opts?: { limit?: number; cursor?: string }): Promise<RowsPage<T>> {
47
+ const appId = await resolveAppId();
48
+ if (!appId) return { rows: [], nextCursor: null };
49
+ try {
50
+ const params = new URLSearchParams();
51
+ if (opts?.limit) params.set("limit", String(opts.limit));
52
+ if (opts?.cursor) params.set("cursor", opts.cursor);
53
+ const qs = params.toString();
54
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}${qs ? `?${qs}` : ""}`, { headers: authHeaders() });
55
+ if (!res.ok) return { rows: [], nextCursor: null };
56
+ const data = (await res.json()) as { rows: T[]; next_cursor: string | null };
57
+ return { rows: data.rows ?? [], nextCursor: data.next_cursor ?? null };
58
+ } catch {
59
+ return { rows: [], nextCursor: null };
60
+ }
61
+ }
62
+
63
+ // Convenience wrapper every existing generated app already calls expecting
64
+ // a plain array back. Auto-pages up to MAX_AUTO_PAGES so a table's growth
65
+ // no longer silently truncates at a hard 1000-row ceiling -- but this is
66
+ // still a "fetch it all into memory" call, not real pagination; a table
67
+ // genuinely headed into the tens of thousands of rows should move to
68
+ // listRowsPage() above and render incrementally instead.
69
+ const MAX_AUTO_PAGES = 20; // 20 x 1000-row pages = 20,000 rows before this stops and returns what it has
70
+
71
+ export async function listRows<T extends { id: string }>(table: string): Promise<T[]> {
72
+ const all: T[] = [];
73
+ let cursor: string | undefined;
74
+ for (let page = 0; page < MAX_AUTO_PAGES; page++) {
75
+ const { rows, nextCursor } = await listRowsPage<T>(table, { limit: 1000, cursor });
76
+ all.push(...rows);
77
+ if (!nextCursor) break;
78
+ cursor = nextCursor;
79
+ }
80
+ return all;
81
+ }
82
+
83
+ export async function upsertRow<T extends { id: string }>(table: string, row: T): Promise<void> {
84
+ const appId = await resolveAppId();
85
+ if (!appId) return;
86
+ try {
87
+ await fetch(`${window.location.origin}/api/apps/${appId}/data/${table}`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json", ...authHeaders() },
90
+ body: JSON.stringify({ rows: [row] }),
91
+ });
92
+ } catch {
93
+ // best-effort -- a write failure shouldn't crash the app for the person using it
94
+ }
95
+ }
96
+
97
+ export async function deleteRow(table: string, id: string): Promise<void> {
98
+ const appId = await resolveAppId();
99
+ if (!appId) return;
100
+ try {
101
+ await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
102
+ method: "DELETE",
103
+ headers: authHeaders(),
104
+ });
105
+ } catch {
106
+ // best-effort
107
+ }
108
+ }
109
+
110
+ export async function reportError(message: string, stack?: string): Promise<void> {
111
+ const appId = await resolveAppId();
112
+ if (!appId) return;
113
+ try {
114
+ await fetch(`${window.location.origin}/api/apps/${appId}/errors`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({ message, details: { stack, url: window.location.href } }),
118
+ });
119
+ } catch {
120
+ // best-effort
121
+ }
122
+ }
package/src/icons.tsx CHANGED
@@ -109,6 +109,14 @@ export function MenuIcon(props: IconProps = {}) {
109
109
  );
110
110
  }
111
111
 
112
+ export function ChevronDownIcon(props: IconProps = {}) {
113
+ return (
114
+ <svg {...svgProps(props)}>
115
+ <polyline points="6 9 12 15 18 9" />
116
+ </svg>
117
+ );
118
+ }
119
+
112
120
  export function BellIcon(props: IconProps = {}) {
113
121
  return (
114
122
  <svg {...svgProps(props)}>
package/src/index.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from "./icons";
2
2
  export * from "./components";
3
+ export * from "./nav";
4
+ export * from "./data";
5
+ export * from "./auth";
package/src/nav.tsx ADDED
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ // Responsive app navigation shell -- a fixed left sidebar on desktop that
4
+ // collapses to a hamburger-triggered slide-in drawer on mobile. Built
5
+ // specifically to avoid three real, previously-shipped mobile-nav bugs
6
+ // (all found and fixed on viibestack.ai's own marketing site in this same
7
+ // session, then generalized here so every generated app gets the fix by
8
+ // default instead of each one re-discovering it):
9
+ // 1. A drawer positioned with a viewport-relative offset (`right: -100vw`)
10
+ // can end up off-screen entirely, because `vw` resolves against the
11
+ // layout viewport, not the visual one, and the two can differ. Fixed
12
+ // here by only ever mounting the drawer when open (no transform/
13
+ // offset math at all -- `mobileOpen && <div>...`).
14
+ // 2. A CSS checkbox-hack accordion (hidden checkbox + label, `:checked ~`
15
+ // sibling selector) can register a tap as toggling the checkbox twice
16
+ // in one gesture on real mobile browsers, closing a group the instant
17
+ // it opens. Fixed by using the native `<details>/<summary>` disclosure
18
+ // element instead, which the browser's own tap handling manages
19
+ // correctly -- no custom event/state logic to get wrong.
20
+ // 3. Flex children with `flex-shrink` (the default) let a wrapping
21
+ // container squeeze itself into a tall, single-column, no-line-break
22
+ // instead of ever collapsing to the intended stacked layout. Avoided
23
+ // here by never using flex-wrap for this layout at all.
24
+ import { createPortal } from "react-dom";
25
+ import { useEffect, useState, type ReactNode } from "react";
26
+ import { MenuIcon, CloseIcon, ChevronDownIcon } from "./icons";
27
+
28
+ function cx(...parts: (string | false | null | undefined)[]): string {
29
+ return parts.filter(Boolean).join(" ");
30
+ }
31
+
32
+ export interface NavLinkItem {
33
+ href: string;
34
+ label: string;
35
+ icon?: ReactNode;
36
+ }
37
+
38
+ export interface NavGroupItem {
39
+ label: string;
40
+ icon?: ReactNode;
41
+ items: NavLinkItem[];
42
+ }
43
+
44
+ export type AppNavItem = NavLinkItem | NavGroupItem;
45
+
46
+ function isGroup(item: AppNavItem): item is NavGroupItem {
47
+ return "items" in item;
48
+ }
49
+
50
+ const LINK_CLASS =
51
+ "flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800";
52
+ const LINK_ACTIVE_CLASS = "bg-primary/10 text-primary dark:bg-primary/20";
53
+
54
+ export interface AppShellProps {
55
+ /** Logo/product name, shown in the desktop sidebar header and mobile topbar/drawer. */
56
+ brand: ReactNode;
57
+ nav: AppNavItem[];
58
+ /** Current path, used to highlight the active link -- compare against each item's href. */
59
+ activeHref?: string;
60
+ /**
61
+ * Override how a single link renders (e.g. to use Next.js's `<Link>` for
62
+ * client-side navigation instead of a plain `<a>`, which would trigger a
63
+ * full page reload). Defaults to a plain anchor tag.
64
+ */
65
+ renderLink?: (item: NavLinkItem, active: boolean) => ReactNode;
66
+ /** Rendered at the bottom of the sidebar/drawer -- e.g. a user menu, sign-out button. */
67
+ footer?: ReactNode;
68
+ children: ReactNode;
69
+ }
70
+
71
+ export function AppShell({ brand, nav, activeHref, renderLink, footer, children }: AppShellProps) {
72
+ const [mobileOpen, setMobileOpen] = useState(false);
73
+ const [mounted, setMounted] = useState(false);
74
+
75
+ useEffect(() => setMounted(true), []);
76
+
77
+ useEffect(() => {
78
+ if (!mobileOpen) return;
79
+ function onKey(e: KeyboardEvent) {
80
+ if (e.key === "Escape") setMobileOpen(false);
81
+ }
82
+ document.addEventListener("keydown", onKey);
83
+ const prevOverflow = document.body.style.overflow;
84
+ document.body.style.overflow = "hidden";
85
+ return () => {
86
+ document.removeEventListener("keydown", onKey);
87
+ document.body.style.overflow = prevOverflow;
88
+ };
89
+ }, [mobileOpen]);
90
+
91
+ function defaultLink(item: NavLinkItem, active: boolean) {
92
+ return (
93
+ <a href={item.href} className={cx(LINK_CLASS, active && LINK_ACTIVE_CLASS)}>
94
+ {item.icon}
95
+ {item.label}
96
+ </a>
97
+ );
98
+ }
99
+ const link = renderLink ?? defaultLink;
100
+
101
+ function renderItem(item: AppNavItem, key: number, onNavigate?: () => void) {
102
+ if (isGroup(item)) {
103
+ const groupHasActive = item.items.some((sub) => sub.href === activeHref);
104
+ return (
105
+ <details key={key} open={groupHasActive} className="group">
106
+ <summary
107
+ className={cx(LINK_CLASS, "cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden")}
108
+ >
109
+ <span className="flex items-center gap-2">
110
+ {item.icon}
111
+ {item.label}
112
+ </span>
113
+ <ChevronDownIcon size={16} className="shrink-0 transition-transform group-open:rotate-180" />
114
+ </summary>
115
+ <div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-gray-200 pl-3 dark:border-gray-800">
116
+ {item.items.map((sub, i) => (
117
+ <div key={i} onClick={onNavigate}>
118
+ {link(sub, sub.href === activeHref)}
119
+ </div>
120
+ ))}
121
+ </div>
122
+ </details>
123
+ );
124
+ }
125
+ return (
126
+ <div key={key} onClick={onNavigate}>
127
+ {link(item, item.href === activeHref)}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
134
+ {/* Desktop sidebar -- unchanged fixed layout, hidden below md */}
135
+ <aside className="hidden md:flex md:w-56 md:shrink-0 md:flex-col md:border-r md:border-gray-200 md:bg-white md:dark:border-gray-800 md:dark:bg-gray-900">
136
+ <div className="px-4 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
137
+ <nav className="flex flex-col gap-1 overflow-y-auto px-3 pb-3">
138
+ {nav.map((item, i) => renderItem(item, i))}
139
+ </nav>
140
+ {footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
141
+ </aside>
142
+
143
+ <div className="flex min-w-0 flex-1 flex-col">
144
+ {/* Mobile topbar -- hidden at md and above */}
145
+ <header className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-900 md:hidden">
146
+ <div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
147
+ <button
148
+ type="button"
149
+ onClick={() => setMobileOpen(true)}
150
+ aria-label="Open menu"
151
+ className="rounded-lg p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
152
+ >
153
+ <MenuIcon />
154
+ </button>
155
+ </header>
156
+
157
+ <main className="flex-1 overflow-auto">{children}</main>
158
+ </div>
159
+
160
+ {/* Mobile drawer -- only exists in the DOM while open, portaled to
161
+ document.body so it's never clipped by an ancestor's overflow.
162
+ `right: 0` with a fixed pixel width (never a vw-based offset) is
163
+ what keeps this from ending up off-screen -- see the file header. */}
164
+ {mounted &&
165
+ mobileOpen &&
166
+ createPortal(
167
+ <div className="fixed inset-0 z-50 md:hidden">
168
+ <div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
169
+ <div className="absolute inset-y-0 right-0 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-white shadow-xl dark:bg-gray-900">
170
+ <div className="flex items-center justify-between px-4 py-4">
171
+ <div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
172
+ <button
173
+ type="button"
174
+ onClick={() => setMobileOpen(false)}
175
+ aria-label="Close menu"
176
+ className="rounded-lg p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
177
+ >
178
+ <CloseIcon />
179
+ </button>
180
+ </div>
181
+ <nav className="flex flex-col gap-1 px-3 pb-3">
182
+ {nav.map((item, i) => renderItem(item, i, () => setMobileOpen(false)))}
183
+ </nav>
184
+ {footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
185
+ </div>
186
+ </div>,
187
+ document.body,
188
+ )}
189
+ </div>
190
+ );
191
+ }