@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 +6 -3
- package/src/auth.ts +332 -0
- package/src/data.ts +122 -0
- package/src/icons.tsx +8 -0
- package/src/index.ts +3 -0
- package/src/nav.tsx +191 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viibestack/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dependency-free React UI kit -- icons
|
|
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
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
|
+
}
|