From 99fc23e55fd8cdb179a3655e1c9b1e385e1108c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 14:00:47 +0000 Subject: [PATCH 01/11] Add standalone (not-signed-in) mode Let the app run without an Onshape login: opening it directly serves the read-only library UI backed by the loaded database. Everything works except Onshape-dependent actions, which are hidden client-side and guarded server-side behind sign-in. - Backend: `isSignedIn` util + `requireSignInMiddleware` mirroring the access-level helper, applied to favorites, insert, user-data, and live-preview thumbnail routes. Service factory tolerates not-signed-in callers; a new `FORCE_SIGNED_IN` testing var forces sign-in with a fake user. - `/context-data` exposes `signedIn`; not-signed-in returns default settings. - Frontend: `RequireSignIn`/`useIsSignedIn` gate the insert and favorites UI; the units call is skipped (default units), the preview falls back to the static thumbnail, Onshape messaging is a no-op, settings persist to localStorage, and a `/` route provides a direct entry point. --- README.md | 25 ++ src/__test_utils__/test-app.ts | 11 +- src/backend/app.ts | 4 + src/backend/routes/favorites.ts | 4 + src/backend/routes/insertables.ts | 3 + src/backend/routes/sign-in.test.ts | 77 ++++ src/backend/routes/thumbnails.ts | 4 +- src/backend/routes/user.test.ts | 3 +- src/backend/routes/user.ts | 54 ++- src/backend/services.ts | 23 +- src/backend/sign-in-utils.ts | 46 +++ src/frontend/api-utils/messages.ts | 13 +- src/frontend/api-utils/sign-in.tsx | 15 + src/frontend/cards/card-components.tsx | 5 +- src/frontend/cards/insertable-card.tsx | 5 +- src/frontend/favorites/favorite-button.tsx | 65 ++-- src/frontend/favorites/favorite-menu.tsx | 1 + src/frontend/insert/configurations.tsx | 22 +- src/frontend/insert/insert-menu.tsx | 16 +- src/frontend/insert/thumbnail.tsx | 31 +- src/frontend/queries.ts | 20 +- src/frontend/routeTree.gen.ts | 415 +++++++++++---------- src/frontend/routes/app/groups/index.tsx | 5 +- src/frontend/routes/index.tsx | 19 + src/frontend/settings/local-settings.ts | 23 ++ src/frontend/settings/settings.ts | 15 +- src/shared/configuration-models.ts | 9 + src/shared/types.ts | 2 + 28 files changed, 663 insertions(+), 272 deletions(-) create mode 100644 src/backend/routes/sign-in.test.ts create mode 100644 src/backend/sign-in-utils.ts create mode 100644 src/frontend/api-utils/sign-in.tsx create mode 100644 src/frontend/routes/index.tsx create mode 100644 src/frontend/settings/local-settings.ts diff --git a/README.md b/README.md index b679b04c6..0d160cdcf 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,31 @@ To see documents, add one or more documents and push a new app version to rebuil To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance. +## Standalone (not-signed-in) mode + +The app also runs without an Onshape login. Opening it directly (e.g. +`https://localhost:3000/`, rather than launching it from the Onshape panel) +serves the read-only library UI: browse groups, search, and open the +configuration menu. Anything that needs Onshape — inserting/deriving, favorites, +saving settings server-side, and live configuration previews — is hidden and +guarded server-side behind sign-in. Settings (theme/library) fall back to +`localStorage`, and the configuration menu uses default units and the stored +(static) thumbnail. + +This needs a populated database. Import a dump of the loaded cert DB into local +D1, and (optionally) its thumbnails into local R2: + +``` +npx wrangler d1 execute DB --local --file=.sql +npx wrangler r2 object put frc-design-app-dev-thumbnails/thumbnails// --file=.gif +``` + +To exercise the signed-in-only UI (favorites, insert button) without a real +Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a +testing-only escape hatch — it uses a fake user id and Onshape calls it reveals +won't actually work, so leave it unset normally. Combine with +`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls. + # Troubleshooting ## Onshape fails to load diff --git a/src/__test_utils__/test-app.ts b/src/__test_utils__/test-app.ts index afce96640..f9710b1cf 100644 --- a/src/__test_utils__/test-app.ts +++ b/src/__test_utils__/test-app.ts @@ -9,6 +9,11 @@ export interface TestAppOptions { accessLevel?: AccessLevel; /** Onshape mock returned by `c.var.getOnshapeApi()` (default a fresh mock). */ onshapeApi?: MockOnshapeApi; + /** + * When false, `getOnshapeApi` rejects so `isSignedIn()` is false (simulating + * a not-signed-in caller). Default true. + */ + signedIn?: boolean; } /** @@ -17,8 +22,12 @@ export interface TestAppOptions { * `app.request(path, init, env)` (pass `env` from `cloudflare:workers`). */ export function createTestApp(options: TestAppOptions = {}) { + const signedIn = options.signedIn ?? true; return createApp(() => ({ - getOnshapeApi: () => Promise.resolve(MOCK_ONSHAPE_API), + getOnshapeApi: () => + signedIn + ? Promise.resolve(options.onshapeApi ?? MOCK_ONSHAPE_API) + : Promise.reject(new Error("Not signed in")), getUserId: () => Promise.resolve(options.userId ?? "test-user"), getAccessLevel: () => Promise.resolve(options.accessLevel ?? AccessLevel.ADMIN) diff --git a/src/backend/app.ts b/src/backend/app.ts index 12fa0db1f..6aec2d866 100644 --- a/src/backend/app.ts +++ b/src/backend/app.ts @@ -14,11 +14,15 @@ export interface AppBindings { ADD_GROUP_WORKFLOW: Workflow; ADMIN_TEAM: string; ACCESS_LEVEL_OVERRIDE?: string; + /** Testing-only: treat requests as signed in with a fake user. Not for production. */ + FORCE_SIGNED_IN?: string; } interface AppVariables { /** Internal cache for {@link getOnshapeApi} in auth.ts. */ onshapeApi?: OAuthApi; + /** Internal cache for isSignedIn in sign-in-utils.ts. */ + signedIn?: boolean; /** Injected getters — see {@link AppServices} / `createApp`. */ getOnshapeApi: () => Promise; getUserId: () => Promise; diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index 429fde7b8..d5ffceaec 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -5,9 +5,13 @@ import { users, favorites } from "../../shared/schema"; import { type Favorite, type FavoritesData } from "../../shared/api-models"; import { type LibraryId } from "../../shared/types"; import { type ParameterValues } from "../../shared/configuration-models"; +import { requireSignInMiddleware } from "../sign-in-utils"; export const favoriteRoutes = getApp(); +// Favorites are per-user and require a signed-in Onshape session. +favoriteRoutes.use(requireSignInMiddleware); + async function getFavorites( db: Db, userId: string, diff --git a/src/backend/routes/insertables.ts b/src/backend/routes/insertables.ts index f4193ec36..282b17170 100644 --- a/src/backend/routes/insertables.ts +++ b/src/backend/routes/insertables.ts @@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception"; import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getDb, type Db } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; +import { requireSignInMiddleware } from "../sign-in-utils"; import { insertables, configurations } from "../../shared/schema"; import { bumpLibraryVersion, rebuildSearchDb } from "../library-data"; import { type ElementPath } from "../../shared/onshape-path"; @@ -224,6 +225,7 @@ insertableRoutes.post( "/add-to-part-studio" + insertableRoute() + "/d/:documentId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); @@ -294,6 +296,7 @@ insertableRoutes.post( "/add-to-assembly" + insertableRoute() + "/d/:documentId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const insertableId = getInsertableParam(c); diff --git a/src/backend/routes/sign-in.test.ts b/src/backend/routes/sign-in.test.ts new file mode 100644 index 000000000..0124ea4b2 --- /dev/null +++ b/src/backend/routes/sign-in.test.ts @@ -0,0 +1,77 @@ +import { env } from "cloudflare:workers"; +import { beforeEach, describe, expect, it } from "vitest"; +import { AccessLevel, LibraryId, Theme } from "../../shared/types"; +import { + createTestApp, + jsonRequest, + resetDb, + seedLibrary +} from "../../__test_utils__"; +import { getDb } from "../db"; + +const db = getDb(env.DB); + +describe("not-signed-in access", () => { + beforeEach(async () => { + await resetDb(db); + }); + + it("GET /context-data returns defaults with signedIn: false", async () => { + await seedLibrary(db); + // accessLevel is injected as ADMIN, but the not-signed-in branch ignores + // it and reports a plain USER so the read-only UI loads. + const app = createTestApp({ + signedIn: false, + accessLevel: AccessLevel.ADMIN + }); + + const res = await app.request( + "/api/context-data", + jsonRequest("GET"), + env + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + accessData: { + maxAccessLevel: AccessLevel.USER, + currentAccessLevel: AccessLevel.USER, + cacheVersion: 0 + }, + settings: { + theme: Theme.SYSTEM, + libraryId: LibraryId.FRC_DESIGN_LIB + }, + signedIn: false + }); + }); + + it("blocks sign-in-only routes with 401 when not signed in", async () => { + const app = createTestApp({ signedIn: false }); + + const favorites = await app.request( + "/api/favorites/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("GET"), + env + ); + expect(favorites.status).toBe(401); + + const userData = await app.request( + "/api/user-data", + jsonRequest("POST", { theme: Theme.DARK }), + env + ); + expect(userData.status).toBe(401); + }); + + it("allows sign-in-only routes when signed in", async () => { + await seedLibrary(db); + const app = createTestApp({ signedIn: true }); + + const favorites = await app.request( + "/api/favorites/library/" + LibraryId.FRC_DESIGN_LIB, + jsonRequest("GET"), + env + ); + expect(favorites.status).toBe(200); + }); +}); diff --git a/src/backend/routes/thumbnails.ts b/src/backend/routes/thumbnails.ts index 9789a9e33..64972d41c 100644 --- a/src/backend/routes/thumbnails.ts +++ b/src/backend/routes/thumbnails.ts @@ -3,6 +3,7 @@ import { getApp, getInsertableParam, insertableRoute } from "../app"; import { getInsertableElementPath } from "./insertables"; import { getDb } from "../db"; import { requireEditorMiddleware } from "../access-level-utils"; +import { requireSignInMiddleware } from "../sign-in-utils"; import { bumpLibraryVersion } from "../library-data"; import { getElementThumbnail, @@ -135,7 +136,7 @@ thumbnailRoutes.get("/thumbnail/:size/:elementId", async (c) => { }); /** GET /api/thumbnail?size=X&thumbnailId=Y — live preview thumbnail from Onshape */ -thumbnailRoutes.get("/thumbnail", async (c) => { +thumbnailRoutes.get("/thumbnail", requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const size = (c.req.query("size") as ThumbnailSize) ?? ThumbnailSize.STANDARD; @@ -154,6 +155,7 @@ thumbnailRoutes.get("/thumbnail", async (c) => { /** GET /api/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId */ thumbnailRoutes.get( "/thumbnail-id/d/:docId/:instanceType/:instanceId/e/:elementId", + requireSignInMiddleware, async (c) => { const onshapeApi = await c.var.getOnshapeApi(); const elementPath: ElementPath = { diff --git a/src/backend/routes/user.test.ts b/src/backend/routes/user.test.ts index d99722446..2a2a41d3f 100644 --- a/src/backend/routes/user.test.ts +++ b/src/backend/routes/user.test.ts @@ -42,7 +42,8 @@ describe("user routes", () => { settings: { theme: Theme.SYSTEM, libraryId: LibraryId.FRC_DESIGN_LIB - } + }, + signedIn: true }); }); diff --git a/src/backend/routes/user.ts b/src/backend/routes/user.ts index c63c74ea0..58ed99600 100644 --- a/src/backend/routes/user.ts +++ b/src/backend/routes/user.ts @@ -2,16 +2,53 @@ import { eq } from "drizzle-orm"; import { getApp } from "../app"; import { getDb } from "../db"; import { users, libraries } from "../../shared/schema"; -import { LibraryId, ContextData, Theme, AccessLevel } from "../../shared/types"; +import { + LibraryId, + ContextData, + Theme, + AccessLevel, + DEFAULT_SETTINGS +} from "../../shared/types"; +import { isSignedIn, requireSignInMiddleware } from "../sign-in-utils"; import { env } from "process"; export const userRoutes = getApp(); +async function getLibraryCacheVersion( + db: ReturnType, + libraryId: LibraryId +): Promise { + const lib = await db + .select({ cacheVersion: libraries.cacheVersion }) + .from(libraries) + .where(eq(libraries.id, libraryId)) + .get(); + return lib?.cacheVersion ?? 0; +} + /** GET /api/context-data */ userRoutes.get("/context-data", async (c) => { + const db = getDb(c.env.DB); + + // A not-signed-in caller has no user row; serve defaults so the read-only UI + // still loads. Settings are then overlaid from localStorage on the client. + if (!(await isSignedIn(c))) { + return c.json({ + accessData: { + maxAccessLevel: AccessLevel.USER, + currentAccessLevel: AccessLevel.USER, + cacheVersion: await getLibraryCacheVersion( + db, + DEFAULT_SETTINGS.libraryId + ) + }, + settings: DEFAULT_SETTINGS, + signedIn: false + } satisfies ContextData); + } + const userId = await c.var.getUserId(); const maxAccessLevel = await c.var.getAccessLevel(); - const db = getDb(c.env.DB); let user = await db.select().from(users).where(eq(users.id, userId)).get(); if (!user) { @@ -22,11 +59,7 @@ userRoutes.get("/context-data", async (c) => { }; } - const lib = await db - .select({ cacheVersion: libraries.cacheVersion }) - .from(libraries) - .where(eq(libraries.id, user.libraryId)) - .get(); + const cacheVersion = await getLibraryCacheVersion(db, user.libraryId); // Always default to user in dev and the max in production const currentAccessLevel = @@ -36,17 +69,18 @@ userRoutes.get("/context-data", async (c) => { accessData: { maxAccessLevel, currentAccessLevel, - cacheVersion: lib?.cacheVersion ?? 0 + cacheVersion }, settings: { theme: user.theme, libraryId: user.libraryId - } + }, + signedIn: true } satisfies ContextData); }); /** POST /api/user-data — update settings */ -userRoutes.post("/user-data", async (c) => { +userRoutes.post("/user-data", requireSignInMiddleware, async (c) => { const userId = await c.var.getUserId(); const body = await c.req.json<{ theme?: Theme; libraryId?: LibraryId }>(); diff --git a/src/backend/services.ts b/src/backend/services.ts index 8a555a763..9a19b4cf1 100644 --- a/src/backend/services.ts +++ b/src/backend/services.ts @@ -2,19 +2,36 @@ import { type AppServicesFactory } from "./app"; import { getOnshapeApi } from "./auth"; import { getUserId } from "./onshape-api/endpoints/users"; import { getCachedAccessLevel } from "./access-level-utils"; -import { type AccessLevel } from "../shared/types"; +import { isSignedIn } from "./sign-in-utils"; +import { AccessLevel } from "../shared/types"; + +/** Stable fake user id used for FORCE_SIGNED_IN testing sessions. */ +export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; /** * Production dependency wiring: resolves the Onshape API from the session, the * userId from session info, and the access level from the user's admin-team * membership (honoring the access level override and KV cache). + * + * `getUserId`/`getAccessLevel` also tolerate not-signed-in requests: `getUserId` + * is only reached behind `requireSignInMiddleware` (or the signed-in branch of + * /context-data), and `getAccessLevel` falls back to USER. */ export const productionServices: AppServicesFactory = (c) => ({ getOnshapeApi: () => getOnshapeApi(c), - getUserId: async () => getUserId(await getOnshapeApi(c)), + getUserId: async () => { + // FORCE_SIGNED_IN has no real Onshape session; use a stable fake id. + if (c.env.FORCE_SIGNED_IN) return FORCE_SIGNED_IN_USER_ID; + return getUserId(await getOnshapeApi(c)); + }, getAccessLevel: async () => { const override = c.env.ACCESS_LEVEL_OVERRIDE; if (override) return override as AccessLevel; - return getCachedAccessLevel(c); + // getCachedAccessLevel needs a real Onshape session, so only call it + // for a genuinely signed-in caller (not FORCE_SIGNED_IN). + if (!c.env.FORCE_SIGNED_IN && (await isSignedIn(c))) { + return getCachedAccessLevel(c); + } + return AccessLevel.USER; } }); diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts new file mode 100644 index 000000000..930dfdaf5 --- /dev/null +++ b/src/backend/sign-in-utils.ts @@ -0,0 +1,46 @@ +import type { MiddlewareHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { type AppContext, type AppContextEnv } from "./app"; + +/** + * Whether the caller has a valid Onshape session, memoized on the request. + * + * Resolves through the injected `getOnshapeApi` (which needs a session cookie + + * valid tokens), so it honors test/standalone wiring. `FORCE_SIGNED_IN` forces + * it true for testing (see services.ts for the matching fake userId). + */ +export async function isSignedIn(c: AppContext): Promise { + const cached = c.get("signedIn"); + if (cached !== undefined) return cached; + + let signedIn: boolean; + if (c.env.FORCE_SIGNED_IN) { + signedIn = true; + } else { + try { + await c.var.getOnshapeApi(); + signedIn = true; + } catch { + signedIn = false; + } + } + + c.set("signedIn", signedIn); + return signedIn; +} + +/** + * Middleware which requires the caller to be signed in to Onshape. + */ +export const requireSignInMiddleware: MiddlewareHandler = async ( + c, + next +) => { + if (!(await isSignedIn(c))) { + throw new HTTPException(401, { + message: + "You must be signed in to Onshape to use this functionality" + }); + } + await next(); +}; diff --git a/src/frontend/api-utils/messages.ts b/src/frontend/api-utils/messages.ts index e9562ec6b..f591cc031 100644 --- a/src/frontend/api-utils/messages.ts +++ b/src/frontend/api-utils/messages.ts @@ -10,13 +10,18 @@ import { useSearch } from "@tanstack/react-router"; import { type ElementPath } from "../../shared/onshape-path"; import { useCallback, useEffect } from "react"; +import { useIsSignedIn } from "./sign-in"; export function useMessageListener() { const search = useSearch({ from: "/app" }); + // Not signed in means no Onshape iframe parent to message. + const isSignedIn = useIsSignedIn(); useEffect(() => { - sendInitMessage(search); - }, [search]); + if (isSignedIn) { + sendInitMessage(search); + } + }, [search, isSignedIn]); useEffect(() => { const handlePostMessage = (event: MessageEvent) => { @@ -38,11 +43,13 @@ export function useMessageListener() { export function useMessageSender() { const search = useSearch({ from: "/app" }); + const isSignedIn = useIsSignedIn(); return useCallback( (message: Message) => { + if (!isSignedIn) return; sendMessage(search, message); }, - [search] + [search, isSignedIn] ); } diff --git a/src/frontend/api-utils/sign-in.tsx b/src/frontend/api-utils/sign-in.tsx new file mode 100644 index 000000000..d44b4e0b5 --- /dev/null +++ b/src/frontend/api-utils/sign-in.tsx @@ -0,0 +1,15 @@ +import { PropsWithChildren } from "react"; +import { useLoaderData } from "@tanstack/react-router"; + +/** Whether the caller is signed in to Onshape (from context-data). */ +export function useIsSignedIn(): boolean { + return useLoaderData({ from: "/app" }).signedIn; +} + +/** + * Renders children only when the user is signed in to Onshape. The mirror of + * {@link RequireAccessLevel} for sign-in-gated UI (insert, favorites, ...). + */ +export function RequireSignIn(props: PropsWithChildren) { + return useIsSignedIn() ? props.children : null; +} diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 05880902e..c8beaba2b 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -26,6 +26,7 @@ import { ThumbnailUrls } from "../../shared/types"; import { ParameterValues } from "../../shared/configuration-models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../api-utils/access-level"; +import { RequireSignIn } from "../api-utils/sign-in"; import { useReloadThumbnailMutation } from "./card-hooks"; interface OpenDocumentItemsProps { @@ -93,7 +94,7 @@ export function QuickInsertItems(props: QuickInsertItemProps) { search.elementType === ElementType.ASSEMBLY; return ( - <> + {supportsFasten && ( } @@ -108,7 +109,7 @@ export function QuickInsertItems(props: QuickInsertItemProps) { > Quick insert - + ); } diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index a6d8d86e7..3331f4eb1 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -25,6 +25,7 @@ import { openCannotDeriveAssemblyAlert } from "../app/alerts"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; import { useFavoritesQuery } from "../queries"; +import { RequireSignIn } from "../api-utils/sign-in"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; @@ -112,13 +113,13 @@ export function InsertableMenuItems( return ( <> {!inInsertMenu && ( - <> + - + )} { - event.stopPropagation(); - const favoriteId = favorite?.id ?? crypto.randomUUID(); - mutation.mutate({ operation, insertable, favoriteId }); - }} - title={operation === Operation.ADD ? "Favorite" : "Unfavorite"} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {favoriteIcon} - + + { + event.stopPropagation(); + const favoriteId = favorite?.id ?? crypto.randomUUID(); + mutation.mutate({ operation, insertable, favoriteId }); + }} + title={operation === Operation.ADD ? "Favorite" : "Unfavorite"} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {favoriteIcon} + + ); } @@ -154,22 +157,24 @@ export function FavoriteInsertableItem(props: FavoriteInsertableItemProps) { const mutation = useUpdateFavoritesMutation(); return ( - - ) : ( - - ) - } - color={operation === Operation.ADD ? undefined : "red"} - onClick={() => { - const favoriteId = favorite?.id ?? crypto.randomUUID(); - mutation.mutate({ operation, insertable, favoriteId }); - }} - > - {operation === Operation.ADD ? "Favorite" : "Unfavorite"} - + + + ) : ( + + ) + } + color={operation === Operation.ADD ? undefined : "red"} + onClick={() => { + const favoriteId = favorite?.id ?? crypto.randomUUID(); + mutation.mutate({ operation, insertable, favoriteId }); + }} + > + {operation === Operation.ADD ? "Favorite" : "Unfavorite"} + + ); } diff --git a/src/frontend/favorites/favorite-menu.tsx b/src/frontend/favorites/favorite-menu.tsx index 6bdfb7c3d..54f0a79fe 100644 --- a/src/frontend/favorites/favorite-menu.tsx +++ b/src/frontend/favorites/favorite-menu.tsx @@ -120,6 +120,7 @@ function FavoriteMenuContent(props: FavoriteMenuContentProps): ReactNode { { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -88,7 +96,11 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { setConfiguration(defaultConfiguration); }, [query.data, configuration, setConfiguration]); - if (query.isPending || unitInfoQuery.isPending || !configuration) { + if ( + query.isPending || + (isSignedIn && unitInfoQuery.isPending) || + !configuration + ) { return (
@@ -96,7 +108,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { ); } else if (query.isError) { return ; - } else if (unitInfoQuery.isError) { + } else if (isSignedIn && unitInfoQuery.isError) { return ; } @@ -105,7 +117,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { configurationResult={query.data} configuration={configuration} setConfiguration={setConfiguration} - unitInfo={unitInfoQuery.data} + unitInfo={unitInfo} /> ); } diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 1a0528b71..f9efdee7d 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -24,6 +24,7 @@ import { ParameterValues } from "../../shared/configuration-models"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; +import { RequireSignIn } from "../api-utils/sign-in"; interface OpenInsertMenuProps { insertable: InsertableOut; @@ -92,6 +93,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { {parameters} @@ -109,12 +111,14 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { /> - + + + ); diff --git a/src/frontend/insert/thumbnail.tsx b/src/frontend/insert/thumbnail.tsx index 2fcb32de1..5f77992bc 100644 --- a/src/frontend/insert/thumbnail.tsx +++ b/src/frontend/insert/thumbnail.tsx @@ -10,6 +10,7 @@ import { ParameterValues } from "../../shared/configuration-models"; import { encodeConfigurationForQuery } from "../../shared/configuration-utils"; import { getConfigurationMatchKey } from "../queries"; import { SectionError } from "../app-common/app-zero-state"; +import { useIsSignedIn } from "../api-utils/sign-in"; interface HeightAndWidth { height: number; @@ -126,11 +127,14 @@ export function PreviewImageCard(props: PreviewImageProps): ReactNode { interface PreviewImageProps { path: ElementPath; configuration?: ParameterValues; + /** Stored thumbnail, shown instead of the live preview when not signed in. */ + thumbnailUrls?: ThumbnailUrls; } export function PreviewImage(props: PreviewImageProps): ReactNode { - const { path, configuration } = props; + const { path, configuration, thumbnailUrls } = props; const size = ThumbnailSize.SMALL; + const isSignedIn = useIsSignedIn(); const isFetchingConfiguration = useIsFetching({ queryKey: getConfigurationMatchKey() }) > 0; @@ -150,7 +154,7 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { }, // Don't retry since failures are almost certainly due to an invalid configuration retry: false, - enabled: !isFetchingConfiguration + enabled: !isFetchingConfiguration && isSignedIn }); const thumbnailId = thumbnailIdQuery.data; @@ -182,11 +186,32 @@ export function PreviewImage(props: PreviewImageProps): ReactNode { return 15000; }, retry: 5, - enabled: !isFetchingConfiguration && thumbnailId !== undefined + enabled: + !isFetchingConfiguration && thumbnailId !== undefined && isSignedIn }); const heightAndWidth = getHeightAndWidth(size, 0.7); + // Not signed in: no live Onshape preview, so show the stored thumbnail. + if (!isSignedIn) { + const url = thumbnailUrls?.[ThumbnailSize.STANDARD]; + if (!url) { + return ( + + ); + } + return ( + + ); + } + if (thumbnailIdQuery.isError || thumbnailQuery.isError) { return ( ({ queryKey: contextDataQueryKey(), - queryFn: () => apiGet("/context-data") + queryFn: async () => { + const data: ContextData = await apiGet("/context-data"); + // Not signed in: overlay locally-persisted settings over the defaults. + if (!data.signedIn) { + data.settings = { ...data.settings, ...readLocalSettings() }; + } + return data; + } }); } -/** Returns information needed to format unit expressions in the Insert dialog. */ -export function useUnitInfoQuery(instancePath: InstancePath) { +/** + * Returns information needed to format unit expressions in the Insert dialog. + * Hits Onshape, so it must be disabled when the caller isn't signed in. + */ +export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { return useQuery({ queryKey: ["unit-info", instancePath], queryFn: () => @@ -82,7 +93,8 @@ export function useUnitInfoQuery(instancePath: InstancePath) { instanceId: instancePath.instanceId, instanceType: instancePath.instanceType } - }) + }), + enabled }); } diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index 3c62a4f57..fae4a5d6a 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -8,234 +8,255 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from './routes/__root' -import { Route as InitRouteImport } from './routes/init' -import { Route as AppRouteRouteImport } from './routes/app/route' -import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-error' -import { Route as PagesLicenseRouteImport } from './routes/_pages/license' -import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' -import { Route as PagesCookieErrorRouteImport } from './routes/_pages/cookie-error' -import { Route as PagesBetaCompleteRouteImport } from './routes/_pages/beta-complete' -import { Route as AppGroupsIndexRouteImport } from './routes/app/groups/index' -import { Route as AppGroupsGroupIdRouteImport } from './routes/app/groups/$groupId' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as InitRouteImport } from "./routes/init"; +import { Route as AppRouteRouteImport } from "./routes/app/route"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as PagesSafariErrorRouteImport } from "./routes/_pages/safari-error"; +import { Route as PagesLicenseRouteImport } from "./routes/_pages/license"; +import { Route as PagesGrantDeniedRouteImport } from "./routes/_pages/grant-denied"; +import { Route as PagesCookieErrorRouteImport } from "./routes/_pages/cookie-error"; +import { Route as PagesBetaCompleteRouteImport } from "./routes/_pages/beta-complete"; +import { Route as AppGroupsIndexRouteImport } from "./routes/app/groups/index"; +import { Route as AppGroupsGroupIdRouteImport } from "./routes/app/groups/$groupId"; const InitRoute = InitRouteImport.update({ - id: '/init', - path: '/init', - getParentRoute: () => rootRouteImport, -} as any) + id: "/init", + path: "/init", + getParentRoute: () => rootRouteImport +} as any); const AppRouteRoute = AppRouteRouteImport.update({ - id: '/app', - path: '/app', - getParentRoute: () => rootRouteImport, -} as any) + id: "/app", + path: "/app", + getParentRoute: () => rootRouteImport +} as any); +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport +} as any); const PagesSafariErrorRoute = PagesSafariErrorRouteImport.update({ - id: '/_pages/safari-error', - path: '/safari-error', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/safari-error", + path: "/safari-error", + getParentRoute: () => rootRouteImport +} as any); const PagesLicenseRoute = PagesLicenseRouteImport.update({ - id: '/_pages/license', - path: '/license', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/license", + path: "/license", + getParentRoute: () => rootRouteImport +} as any); const PagesGrantDeniedRoute = PagesGrantDeniedRouteImport.update({ - id: '/_pages/grant-denied', - path: '/grant-denied', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/grant-denied", + path: "/grant-denied", + getParentRoute: () => rootRouteImport +} as any); const PagesCookieErrorRoute = PagesCookieErrorRouteImport.update({ - id: '/_pages/cookie-error', - path: '/cookie-error', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/cookie-error", + path: "/cookie-error", + getParentRoute: () => rootRouteImport +} as any); const PagesBetaCompleteRoute = PagesBetaCompleteRouteImport.update({ - id: '/_pages/beta-complete', - path: '/beta-complete', - getParentRoute: () => rootRouteImport, -} as any) + id: "/_pages/beta-complete", + path: "/beta-complete", + getParentRoute: () => rootRouteImport +} as any); const AppGroupsIndexRoute = AppGroupsIndexRouteImport.update({ - id: '/groups/', - path: '/groups/', - getParentRoute: () => AppRouteRoute, -} as any) + id: "/groups/", + path: "/groups/", + getParentRoute: () => AppRouteRoute +} as any); const AppGroupsGroupIdRoute = AppGroupsGroupIdRouteImport.update({ - id: '/groups/$groupId', - path: '/groups/$groupId', - getParentRoute: () => AppRouteRoute, -} as any) + id: "/groups/$groupId", + path: "/groups/$groupId", + getParentRoute: () => AppRouteRoute +} as any); export interface FileRoutesByFullPath { - '/app': typeof AppRouteRouteWithChildren - '/init': typeof InitRoute - '/beta-complete': typeof PagesBetaCompleteRoute - '/cookie-error': typeof PagesCookieErrorRoute - '/grant-denied': typeof PagesGrantDeniedRoute - '/license': typeof PagesLicenseRoute - '/safari-error': typeof PagesSafariErrorRoute - '/app/groups/$groupId': typeof AppGroupsGroupIdRoute - '/app/groups/': typeof AppGroupsIndexRoute + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/init": typeof InitRoute; + "/beta-complete": typeof PagesBetaCompleteRoute; + "/cookie-error": typeof PagesCookieErrorRoute; + "/grant-denied": typeof PagesGrantDeniedRoute; + "/license": typeof PagesLicenseRoute; + "/safari-error": typeof PagesSafariErrorRoute; + "/app/groups/$groupId": typeof AppGroupsGroupIdRoute; + "/app/groups/": typeof AppGroupsIndexRoute; } export interface FileRoutesByTo { - '/app': typeof AppRouteRouteWithChildren - '/init': typeof InitRoute - '/beta-complete': typeof PagesBetaCompleteRoute - '/cookie-error': typeof PagesCookieErrorRoute - '/grant-denied': typeof PagesGrantDeniedRoute - '/license': typeof PagesLicenseRoute - '/safari-error': typeof PagesSafariErrorRoute - '/app/groups/$groupId': typeof AppGroupsGroupIdRoute - '/app/groups': typeof AppGroupsIndexRoute + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/init": typeof InitRoute; + "/beta-complete": typeof PagesBetaCompleteRoute; + "/cookie-error": typeof PagesCookieErrorRoute; + "/grant-denied": typeof PagesGrantDeniedRoute; + "/license": typeof PagesLicenseRoute; + "/safari-error": typeof PagesSafariErrorRoute; + "/app/groups/$groupId": typeof AppGroupsGroupIdRoute; + "/app/groups": typeof AppGroupsIndexRoute; } export interface FileRoutesById { - __root__: typeof rootRouteImport - '/app': typeof AppRouteRouteWithChildren - '/init': typeof InitRoute - '/_pages/beta-complete': typeof PagesBetaCompleteRoute - '/_pages/cookie-error': typeof PagesCookieErrorRoute - '/_pages/grant-denied': typeof PagesGrantDeniedRoute - '/_pages/license': typeof PagesLicenseRoute - '/_pages/safari-error': typeof PagesSafariErrorRoute - '/app/groups/$groupId': typeof AppGroupsGroupIdRoute - '/app/groups/': typeof AppGroupsIndexRoute + __root__: typeof rootRouteImport; + "/": typeof IndexRoute; + "/app": typeof AppRouteRouteWithChildren; + "/init": typeof InitRoute; + "/_pages/beta-complete": typeof PagesBetaCompleteRoute; + "/_pages/cookie-error": typeof PagesCookieErrorRoute; + "/_pages/grant-denied": typeof PagesGrantDeniedRoute; + "/_pages/license": typeof PagesLicenseRoute; + "/_pages/safari-error": typeof PagesSafariErrorRoute; + "/app/groups/$groupId": typeof AppGroupsGroupIdRoute; + "/app/groups/": typeof AppGroupsIndexRoute; } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: - | '/app' - | '/init' - | '/beta-complete' - | '/cookie-error' - | '/grant-denied' - | '/license' - | '/safari-error' - | '/app/groups/$groupId' - | '/app/groups/' - fileRoutesByTo: FileRoutesByTo - to: - | '/app' - | '/init' - | '/beta-complete' - | '/cookie-error' - | '/grant-denied' - | '/license' - | '/safari-error' - | '/app/groups/$groupId' - | '/app/groups' - id: - | '__root__' - | '/app' - | '/init' - | '/_pages/beta-complete' - | '/_pages/cookie-error' - | '/_pages/grant-denied' - | '/_pages/license' - | '/_pages/safari-error' - | '/app/groups/$groupId' - | '/app/groups/' - fileRoutesById: FileRoutesById + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: + | "/" + | "/app" + | "/init" + | "/beta-complete" + | "/cookie-error" + | "/grant-denied" + | "/license" + | "/safari-error" + | "/app/groups/$groupId" + | "/app/groups/"; + fileRoutesByTo: FileRoutesByTo; + to: + | "/" + | "/app" + | "/init" + | "/beta-complete" + | "/cookie-error" + | "/grant-denied" + | "/license" + | "/safari-error" + | "/app/groups/$groupId" + | "/app/groups"; + id: + | "__root__" + | "/" + | "/app" + | "/init" + | "/_pages/beta-complete" + | "/_pages/cookie-error" + | "/_pages/grant-denied" + | "/_pages/license" + | "/_pages/safari-error" + | "/app/groups/$groupId" + | "/app/groups/"; + fileRoutesById: FileRoutesById; } export interface RootRouteChildren { - AppRouteRoute: typeof AppRouteRouteWithChildren - InitRoute: typeof InitRoute - PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute - PagesCookieErrorRoute: typeof PagesCookieErrorRoute - PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute - PagesLicenseRoute: typeof PagesLicenseRoute - PagesSafariErrorRoute: typeof PagesSafariErrorRoute + IndexRoute: typeof IndexRoute; + AppRouteRoute: typeof AppRouteRouteWithChildren; + InitRoute: typeof InitRoute; + PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute; + PagesCookieErrorRoute: typeof PagesCookieErrorRoute; + PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute; + PagesLicenseRoute: typeof PagesLicenseRoute; + PagesSafariErrorRoute: typeof PagesSafariErrorRoute; } -declare module '@tanstack/react-router' { - interface FileRoutesByPath { - '/init': { - id: '/init' - path: '/init' - fullPath: '/init' - preLoaderRoute: typeof InitRouteImport - parentRoute: typeof rootRouteImport +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/init": { + id: "/init"; + path: "/init"; + fullPath: "/init"; + preLoaderRoute: typeof InitRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/app": { + id: "/app"; + path: "/app"; + fullPath: "/app"; + preLoaderRoute: typeof AppRouteRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/": { + id: "/"; + path: "/"; + fullPath: "/"; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/safari-error": { + id: "/_pages/safari-error"; + path: "/safari-error"; + fullPath: "/safari-error"; + preLoaderRoute: typeof PagesSafariErrorRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/license": { + id: "/_pages/license"; + path: "/license"; + fullPath: "/license"; + preLoaderRoute: typeof PagesLicenseRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/grant-denied": { + id: "/_pages/grant-denied"; + path: "/grant-denied"; + fullPath: "/grant-denied"; + preLoaderRoute: typeof PagesGrantDeniedRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/cookie-error": { + id: "/_pages/cookie-error"; + path: "/cookie-error"; + fullPath: "/cookie-error"; + preLoaderRoute: typeof PagesCookieErrorRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/_pages/beta-complete": { + id: "/_pages/beta-complete"; + path: "/beta-complete"; + fullPath: "/beta-complete"; + preLoaderRoute: typeof PagesBetaCompleteRouteImport; + parentRoute: typeof rootRouteImport; + }; + "/app/groups/": { + id: "/app/groups/"; + path: "/groups"; + fullPath: "/app/groups/"; + preLoaderRoute: typeof AppGroupsIndexRouteImport; + parentRoute: typeof AppRouteRoute; + }; + "/app/groups/$groupId": { + id: "/app/groups/$groupId"; + path: "/groups/$groupId"; + fullPath: "/app/groups/$groupId"; + preLoaderRoute: typeof AppGroupsGroupIdRouteImport; + parentRoute: typeof AppRouteRoute; + }; } - '/app': { - id: '/app' - path: '/app' - fullPath: '/app' - preLoaderRoute: typeof AppRouteRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/safari-error': { - id: '/_pages/safari-error' - path: '/safari-error' - fullPath: '/safari-error' - preLoaderRoute: typeof PagesSafariErrorRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/license': { - id: '/_pages/license' - path: '/license' - fullPath: '/license' - preLoaderRoute: typeof PagesLicenseRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/grant-denied': { - id: '/_pages/grant-denied' - path: '/grant-denied' - fullPath: '/grant-denied' - preLoaderRoute: typeof PagesGrantDeniedRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/cookie-error': { - id: '/_pages/cookie-error' - path: '/cookie-error' - fullPath: '/cookie-error' - preLoaderRoute: typeof PagesCookieErrorRouteImport - parentRoute: typeof rootRouteImport - } - '/_pages/beta-complete': { - id: '/_pages/beta-complete' - path: '/beta-complete' - fullPath: '/beta-complete' - preLoaderRoute: typeof PagesBetaCompleteRouteImport - parentRoute: typeof rootRouteImport - } - '/app/groups/': { - id: '/app/groups/' - path: '/groups' - fullPath: '/app/groups/' - preLoaderRoute: typeof AppGroupsIndexRouteImport - parentRoute: typeof AppRouteRoute - } - '/app/groups/$groupId': { - id: '/app/groups/$groupId' - path: '/groups/$groupId' - fullPath: '/app/groups/$groupId' - preLoaderRoute: typeof AppGroupsGroupIdRouteImport - parentRoute: typeof AppRouteRoute - } - } } interface AppRouteRouteChildren { - AppGroupsGroupIdRoute: typeof AppGroupsGroupIdRoute - AppGroupsIndexRoute: typeof AppGroupsIndexRoute + AppGroupsGroupIdRoute: typeof AppGroupsGroupIdRoute; + AppGroupsIndexRoute: typeof AppGroupsIndexRoute; } const AppRouteRouteChildren: AppRouteRouteChildren = { - AppGroupsGroupIdRoute: AppGroupsGroupIdRoute, - AppGroupsIndexRoute: AppGroupsIndexRoute, -} + AppGroupsGroupIdRoute: AppGroupsGroupIdRoute, + AppGroupsIndexRoute: AppGroupsIndexRoute +}; const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( - AppRouteRouteChildren, -) + AppRouteRouteChildren +); const rootRouteChildren: RootRouteChildren = { - AppRouteRoute: AppRouteRouteWithChildren, - InitRoute: InitRoute, - PagesBetaCompleteRoute: PagesBetaCompleteRoute, - PagesCookieErrorRoute: PagesCookieErrorRoute, - PagesGrantDeniedRoute: PagesGrantDeniedRoute, - PagesLicenseRoute: PagesLicenseRoute, - PagesSafariErrorRoute: PagesSafariErrorRoute, -} + IndexRoute: IndexRoute, + AppRouteRoute: AppRouteRouteWithChildren, + InitRoute: InitRoute, + PagesBetaCompleteRoute: PagesBetaCompleteRoute, + PagesCookieErrorRoute: PagesCookieErrorRoute, + PagesGrantDeniedRoute: PagesGrantDeniedRoute, + PagesLicenseRoute: PagesLicenseRoute, + PagesSafariErrorRoute: PagesSafariErrorRoute +}; export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/src/frontend/routes/app/groups/index.tsx b/src/frontend/routes/app/groups/index.tsx index 4b4f45f16..b436da92b 100644 --- a/src/frontend/routes/app/groups/index.tsx +++ b/src/frontend/routes/app/groups/index.tsx @@ -21,6 +21,7 @@ import { FavoritesList } from "../../../favorites/favorites-list"; import { useLibraryQuery } from "../../../queries"; import { getLibraryName, useLibraryId } from "../../../api-utils/library"; import { updateUiState, useUiState } from "../../../api-utils/ui-state"; +import { useIsSignedIn } from "../../../api-utils/sign-in"; export const Route = createFileRoute("/app/groups/")({ component: HomeList, @@ -33,6 +34,8 @@ function HomeList(): ReactNode { const [uiState, setUiState] = useUiState(); const [isSearchOpen, setIsSearchOpen] = useState(true); const libraryId = useLibraryId(); + // Favorites are per-user and hidden until signed in. + const isSignedIn = useIsSignedIn(); const isSearch = !!uiState.searchQuery; const listKey = isSearch ? "search" : "library"; @@ -124,7 +127,7 @@ function HomeList(): ReactNode { } }} > - {favoritesAccordion} + {isSignedIn && favoritesAccordion} {childAccordion} diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx new file mode 100644 index 000000000..330625c23 --- /dev/null +++ b/src/frontend/routes/index.tsx @@ -0,0 +1,19 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { getUiState } from "../api-utils/ui-state"; +import { RootAppError } from "../app/root-error"; + +// Direct (not-signed-in) entry point. Onshape launches the app via `/init`; a +// user opening the app directly lands here and is forwarded into the library. +export const Route = createFileRoute("/")({ + beforeLoad: () => { + const uiState = getUiState(); + if (uiState.openGroupId) { + throw redirect({ + to: "/app/groups/$groupId", + params: { groupId: uiState.openGroupId } + }); + } + throw redirect({ to: "/app/groups" }); + }, + errorComponent: RootAppError +}); diff --git a/src/frontend/settings/local-settings.ts b/src/frontend/settings/local-settings.ts new file mode 100644 index 000000000..70ac40a31 --- /dev/null +++ b/src/frontend/settings/local-settings.ts @@ -0,0 +1,23 @@ +import { type Settings } from "../../shared/types"; + +const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; + +/** Reads locally-persisted settings, used when not signed in. */ +export function readLocalSettings(): Partial { + try { + const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); + return raw ? (JSON.parse(raw) as Partial) : {}; + } catch { + return {}; + } +} + +/** Merges and persists settings locally, used when not signed in. */ +export function writeLocalSettings(newSettings: Partial): void { + try { + const merged = { ...readLocalSettings(), ...newSettings }; + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(merged)); + } catch { + // Ignore storage failures (e.g. private browsing). + } +} diff --git a/src/frontend/settings/settings.ts b/src/frontend/settings/settings.ts index 689067d54..27402374f 100644 --- a/src/frontend/settings/settings.ts +++ b/src/frontend/settings/settings.ts @@ -1,16 +1,25 @@ import { useMutation } from "@tanstack/react-query"; -import { LibraryId, Theme } from "../../shared/types"; +import { type Settings } from "../../shared/types"; import { showErrorToast } from "../common/notifications"; import { apiPost } from "../api-utils/api"; import { useUpdateContextData } from "../api-utils/refresh"; +import { useIsSignedIn } from "../api-utils/sign-in"; +import { writeLocalSettings } from "./local-settings"; export function useSaveSettings() { const updateContextData = useUpdateContextData(); + const isSignedIn = useIsSignedIn(); const { mutate } = useMutation({ mutationKey: ["user-data"], - mutationFn: (newSettings: { theme?: Theme; libraryId?: LibraryId }) => - apiPost("/user-data", { body: newSettings }), + mutationFn: async (newSettings: Partial) => { + // Not signed in: no server-side user row; persist locally instead. + if (!isSignedIn) { + writeLocalSettings(newSettings); + return; + } + return apiPost("/user-data", { body: newSettings }); + }, onMutate: (newSettings) => { updateContextData((data) => { data.settings = { ...data.settings, ...newSettings }; diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index 866c5e36a..b0230357a 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -150,3 +150,12 @@ export interface UnitInfo { anglePrecision: number; realPrecision: number; } + +/** Fallback units when the document's units can't be fetched (e.g. not signed in). */ +export const DEFAULT_UNIT_INFO: UnitInfo = { + lengthUnit: Unit.MILLIMETER, + angleUnit: Unit.DEGREE, + lengthPrecision: 3, + anglePrecision: 3, + realPrecision: 3 +}; diff --git a/src/shared/types.ts b/src/shared/types.ts index fa68f8179..f7985091e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -84,6 +84,8 @@ export interface AccessData { export interface ContextData { accessData: AccessData; settings: Settings; + /** Whether the caller has a valid Onshape session (see backend isSignedIn). */ + signedIn: boolean; } export interface ThumbnailUrls { [ThumbnailSize.TINY]: string; From 303725c80d5af2006b0bec8aedfcc66ee42a18af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 14:14:33 +0000 Subject: [PATCH 02/11] Render quantity params in their own unit when not signed in Make unitInfo optional in the configuration UI: when the document's units aren't available (not signed in), each quantity parameter renders in its own default unit (parameter.unit) with a default precision, instead of a global mm/deg fallback. Signed-in behavior is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/frontend/insert/configurations.tsx | 31 +++++++++++++++----------- src/shared/configuration-models.ts | 9 -------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index 220aef2c3..a48f13dc0 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -28,8 +28,7 @@ import { StringParameter, QuantityParameter, UnitInfo, - EnumOption, - DEFAULT_UNIT_INFO + EnumOption } from "../../shared/configuration-models"; import { QuantityType, Unit } from "../../shared/configuration-enums"; import { @@ -76,9 +75,8 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { // default units so the configuration still renders. const isSignedIn = useIsSignedIn(); const unitInfoQuery = useUnitInfoQuery(search, isSignedIn); - const unitInfo = - (isSignedIn ? unitInfoQuery.data : DEFAULT_UNIT_INFO) ?? - DEFAULT_UNIT_INFO; + // Not signed in: no document units, so each quantity renders in its own unit. + const unitInfo = isSignedIn ? unitInfoQuery.data : undefined; useEffect(() => { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -126,7 +124,7 @@ interface ConfigurationParameterProps { configurationResult: ConfigurationResult; configuration: ParameterValues; setConfiguration: Dispatch; - unitInfo: UnitInfo; + unitInfo?: UnitInfo; } function ConfigurationParameters(props: ConfigurationParameterProps) { @@ -170,7 +168,7 @@ interface ParameterProps { onValueChange: (newValue: string | undefined) => void; configuration: ParameterValues; parameters: ConfigurationParameter[]; - unitInfo: UnitInfo; + unitInfo?: UnitInfo; } function ParameterInput( @@ -363,33 +361,40 @@ function StringInput(props: ParameterProps): ReactNode { ); } +/** Fallback display precision when document units aren't available (not signed in). */ +const DEFAULT_QUANTITY_PRECISION = 3; + function getEvaluateOptions( parameter: QuantityParameter, - contextData: UnitInfo + unitInfo?: UnitInfo ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { min: valueWithUnits(parameter.min, parameter.unit), max: valueWithUnits(parameter.max, parameter.unit) }; + // Without document units (not signed in), display each quantity in its own unit. if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: contextData.lengthPrecision, - displayUnit: contextData.lengthUnit, + displayPrecision: + unitInfo?.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.lengthUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: contextData.anglePrecision, - displayUnit: contextData.angleUnit, + displayPrecision: + unitInfo?.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.angleUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType == QuantityType.REAL) { return { quantityType, - displayPrecision: contextData.realPrecision, + displayPrecision: + unitInfo?.realPrecision ?? DEFAULT_QUANTITY_PRECISION, displayUnit: Unit.UNITLESS, ...minAndMax }; diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index b0230357a..866c5e36a 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -150,12 +150,3 @@ export interface UnitInfo { anglePrecision: number; realPrecision: number; } - -/** Fallback units when the document's units can't be fetched (e.g. not signed in). */ -export const DEFAULT_UNIT_INFO: UnitInfo = { - lengthUnit: Unit.MILLIMETER, - angleUnit: Unit.DEGREE, - lengthPrecision: 3, - anglePrecision: 3, - realPrecision: 3 -}; From 2ec381a09812d479ca99ce59ed370a580cb33b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 14:25:37 +0000 Subject: [PATCH 03/11] Add a sign-in button for not-signed-in sessions Show a "Sign in" button in the navbar when not signed in. It kicks off the existing Onshape OAuth flow (/auth/sign-in) and returns the user to their current location; on reload context-data reports them signed in, so no callback query-param plumbing is needed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/frontend/app/app-navbar.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index d4c8f9974..f82a1a2dc 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -20,6 +20,7 @@ import { useUiState } from "../api-utils/ui-state"; import { getLibraryName, useLibraryId } from "../api-utils/library"; import { useSaveSettings } from "../settings/settings"; import { RequireAccessLevel } from "../api-utils/access-level"; +import { useIsSignedIn } from "../api-utils/sign-in"; import { useJobStatus } from "../api-utils/refresh"; import { LibraryId } from "../../shared/types"; @@ -43,12 +44,37 @@ export function AppNavbar(): ReactNode { {leftGroup} + ); } +/** + * Shown only when not signed in. Kicks off the Onshape OAuth flow and returns + * the user to their current location; context-data then reports them signed in. + */ +function SignInButton(): ReactNode { + const isSignedIn = useIsSignedIn(); + if (isSignedIn) return null; + + return ( + + ); +} + /** Editor-only spinner shown while a library-load job is running. */ function JobIndicator(): ReactNode { return ( From 189efcb68b06f7c8f6f9f11d6d48e40c2cf51d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 14:41:00 +0000 Subject: [PATCH 04/11] Omit company_id during standalone sign-in Standalone sign-in doesn't pass sessionCompanyId, so leave company_id off the Onshape authorize URL and let the user pick their account. Onshape-launched sign-in still scopes to the provided company. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/backend/auth.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/backend/auth.ts b/src/backend/auth.ts index dfe7d0e29..cc3866653 100644 --- a/src/backend/auth.ts +++ b/src/backend/auth.ts @@ -101,7 +101,9 @@ authRoutes.get("/sign-in", async (c) => { }); } - const companyId = query.sessionCompanyId ?? "cad"; + // Standalone sign-in omits sessionCompanyId; leave companyId undefined so the + // user can pick their account on Onshape. + const companyId = query.sessionCompanyId; const authorizationUrl = await doSignIn(c, redirectUrl, companyId); return c.redirect(authorizationUrl); }); @@ -118,7 +120,7 @@ authRoutes.get("/callback", async (c) => { export async function doSignIn( c: AppContext, redirectUrl: string, - companyId: string + companyId?: string ): Promise { const oauthClient = getOauthClient(); @@ -132,7 +134,11 @@ export async function doSignIn( state, [] ); - authorizationUrl.searchParams.set("company_id", companyId); + // Onshape-launched sign-in scopes to a company; standalone sign-in omits it + // so the user picks their account. + if (companyId) { + authorizationUrl.searchParams.set("company_id", companyId); + } return authorizationUrl.toString(); } From 9169a14ef549b15b06627d8fc1bb40f74100e52f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:17:37 +0000 Subject: [PATCH 05/11] Tighten comments per AGENTS.md Shorten multi-line doc comments, drop redundant/what-narrating comments, and fix a stale context-data reference. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/backend/services.ts | 5 +---- src/backend/sign-in-utils.ts | 5 +---- src/frontend/app/app-navbar.tsx | 4 ++-- src/frontend/insert/configurations.tsx | 6 ++---- src/frontend/routes/index.tsx | 5 ++--- 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/backend/services.ts b/src/backend/services.ts index c7c182289..9e42a0d22 100644 --- a/src/backend/services.ts +++ b/src/backend/services.ts @@ -9,10 +9,7 @@ export const FORCE_SIGNED_IN_USER_ID = "force-signed-in-user"; /** * Production dependency wiring, memoizing the Onshape lookups in KV by session. - * - * `getUserId`/`getAccessLevel` also tolerate not-signed-in requests: `getUserId` - * is only reached behind `requireSignInMiddleware`, and `getAccessLevel` falls - * back to USER. + * getUserId only runs behind requireSignInMiddleware; getAccessLevel falls back to USER. */ export const productionServices: AppServicesFactory = (c) => ({ getOnshapeApi: () => getOnshapeApi(c), diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts index 930dfdaf5..3c9347d97 100644 --- a/src/backend/sign-in-utils.ts +++ b/src/backend/sign-in-utils.ts @@ -4,10 +4,7 @@ import { type AppContext, type AppContextEnv } from "./app"; /** * Whether the caller has a valid Onshape session, memoized on the request. - * - * Resolves through the injected `getOnshapeApi` (which needs a session cookie + - * valid tokens), so it honors test/standalone wiring. `FORCE_SIGNED_IN` forces - * it true for testing (see services.ts for the matching fake userId). + * `FORCE_SIGNED_IN` forces it true for testing (see services.ts). */ export async function isSignedIn(c: AppContext): Promise { const cached = c.get("signedIn"); diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index 7acc5c3d9..307196e56 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -54,8 +54,8 @@ export function AppNavbar(): ReactNode { } /** - * Shown only when not signed in. Kicks off the Onshape OAuth flow and returns - * the user to their current location; context-data then reports them signed in. + * Shown only when not signed in; starts the Onshape OAuth flow and returns to + * the current location, after which access-data reports the caller signed in. */ function SignInButton(): ReactNode { const isSignedIn = useIsSignedIn(); diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index a48f13dc0..382b29f62 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -71,11 +71,10 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { }); const search = useSearch({ from: "/app" }); - // Fetching document units requires Onshape; when not signed in, fall back to - // default units so the configuration still renders. + // Document units need Onshape; when not signed in, skip the fetch and let + // each quantity render in its own unit (see getEvaluateOptions). const isSignedIn = useIsSignedIn(); const unitInfoQuery = useUnitInfoQuery(search, isSignedIn); - // Not signed in: no document units, so each quantity renders in its own unit. const unitInfo = isSignedIn ? unitInfoQuery.data : undefined; useEffect(() => { @@ -373,7 +372,6 @@ function getEvaluateOptions( min: valueWithUnits(parameter.min, parameter.unit), max: valueWithUnits(parameter.max, parameter.unit) }; - // Without document units (not signed in), display each quantity in its own unit. if (quantityType === QuantityType.LENGTH) { return { quantityType, diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx index 7fec8a227..a79895504 100644 --- a/src/frontend/routes/index.tsx +++ b/src/frontend/routes/index.tsx @@ -3,9 +3,8 @@ import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../../shared/types"; import { readLocalSettings } from "../settings/local-settings"; import { RootAppError } from "../app/root-error"; -// Direct (not-signed-in) entry point. Onshape launches the app through `/init` -// (redirected server-side); a user opening the app directly lands here and is -// forwarded into the library, seeded from their locally-saved settings. +// Direct entry for not-signed-in users (Onshape launches via `/init` instead), +// seeding the library and theme from locally-saved settings. export const Route = createFileRoute("/")({ beforeLoad: () => { const local = readLocalSettings(); From e3b30072b087618d8bafc38077eef80e5f2360ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 17:27:45 +0000 Subject: [PATCH 06/11] Address review: sign-in helpers, favorites fallback, insert-menu UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sign-in-utils: use HttpStatus.UNAUTHORIZED instead of a raw 401. - Move useIsSignedIn/RequireSignIn into access-level.tsx (next to useAccessData/ RequireAccessLevel) and delete the mixed hook+component sign-in.tsx. - Fix useFavoritesQuery to present empty favorites when not signed in — the endpoint 401s and cards/insert menu gate on !favorites, so the library and insert menu were blank for not-signed-in users. - Insert menu when not signed in: always show the stored preview image (no "sign in to preview" error), toast on open, and disable the insert button instead of hiding it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/backend/sign-in-utils.ts | 3 +- src/frontend/api-utils/access-level.tsx | 10 ++++++ src/frontend/api-utils/messages.ts | 2 +- src/frontend/api-utils/sign-in.tsx | 15 -------- src/frontend/app/app-navbar.tsx | 2 +- src/frontend/cards/card-components.tsx | 2 +- src/frontend/cards/insertable-card.tsx | 2 +- src/frontend/favorites/favorite-button.tsx | 2 +- src/frontend/insert/configurations.tsx | 2 +- src/frontend/insert/insert-menu.tsx | 34 +++++++++++++------ src/frontend/insert/thumbnail.tsx | 16 +++------ src/frontend/queries.ts | 10 +++++- .../routes/app/library/$libraryId/index.tsx | 2 +- src/frontend/settings/settings.ts | 2 +- 14 files changed, 56 insertions(+), 48 deletions(-) delete mode 100644 src/frontend/api-utils/sign-in.tsx diff --git a/src/backend/sign-in-utils.ts b/src/backend/sign-in-utils.ts index 3c9347d97..97b0a1f4e 100644 --- a/src/backend/sign-in-utils.ts +++ b/src/backend/sign-in-utils.ts @@ -1,5 +1,6 @@ import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; +import { HttpStatus } from "http-status-ts"; import { type AppContext, type AppContextEnv } from "./app"; /** @@ -34,7 +35,7 @@ export const requireSignInMiddleware: MiddlewareHandler = async ( next ) => { if (!(await isSignedIn(c))) { - throw new HTTPException(401, { + throw new HTTPException(HttpStatus.UNAUTHORIZED, { message: "You must be signed in to Onshape to use this functionality" }); diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/api-utils/access-level.tsx index 1954d3dd0..838f723f2 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/api-utils/access-level.tsx @@ -17,6 +17,11 @@ export function useAccessData(): AccessData { return useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; } +/** Whether the caller is signed in to Onshape (from access-data). */ +export function useIsSignedIn(): boolean { + return useAccessData().signedIn; +} + interface RequireAccessLevelProps extends PropsWithChildren { /** * @optional @@ -53,3 +58,8 @@ export function RequireAccessLevel(props: RequireAccessLevelProps) { } return null; } + +/** Renders children only when the caller is signed in to Onshape. */ +export function RequireSignIn(props: PropsWithChildren) { + return useIsSignedIn() ? props.children : null; +} diff --git a/src/frontend/api-utils/messages.ts b/src/frontend/api-utils/messages.ts index f591cc031..45f450536 100644 --- a/src/frontend/api-utils/messages.ts +++ b/src/frontend/api-utils/messages.ts @@ -10,7 +10,7 @@ import { useSearch } from "@tanstack/react-router"; import { type ElementPath } from "../../shared/onshape-path"; import { useCallback, useEffect } from "react"; -import { useIsSignedIn } from "./sign-in"; +import { useIsSignedIn } from "./access-level"; export function useMessageListener() { const search = useSearch({ from: "/app" }); diff --git a/src/frontend/api-utils/sign-in.tsx b/src/frontend/api-utils/sign-in.tsx deleted file mode 100644 index be140a620..000000000 --- a/src/frontend/api-utils/sign-in.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PropsWithChildren } from "react"; -import { useAccessData } from "./access-level"; - -/** Whether the caller is signed in to Onshape (from access-data). */ -export function useIsSignedIn(): boolean { - return useAccessData().signedIn; -} - -/** - * Renders children only when the user is signed in to Onshape. The mirror of - * {@link RequireAccessLevel} for sign-in-gated UI (insert, favorites, ...). - */ -export function RequireSignIn(props: PropsWithChildren) { - return useIsSignedIn() ? props.children : null; -} diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index 307196e56..c1e610f9d 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -20,7 +20,7 @@ import { useUiState } from "../api-utils/ui-state"; import { getLibraryName, useLibraryId } from "../api-utils/library"; import { RequireAccessLevel } from "../api-utils/access-level"; import { useSaveSettings } from "../settings/settings"; -import { useIsSignedIn } from "../api-utils/sign-in"; +import { useIsSignedIn } from "../api-utils/access-level"; import { useJobStatus } from "../api-utils/refresh"; import { LibraryId } from "../../shared/types"; import { queryClient } from "../query-client"; diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index c8beaba2b..8b9b46caf 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -26,7 +26,7 @@ import { ThumbnailUrls } from "../../shared/types"; import { ParameterValues } from "../../shared/configuration-models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../api-utils/access-level"; -import { RequireSignIn } from "../api-utils/sign-in"; +import { RequireSignIn } from "../api-utils/access-level"; import { useReloadThumbnailMutation } from "./card-hooks"; interface OpenDocumentItemsProps { diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index 3331f4eb1..5f61b7c65 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -25,7 +25,7 @@ import { openCannotDeriveAssemblyAlert } from "../app/alerts"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; import { useFavoritesQuery } from "../queries"; -import { RequireSignIn } from "../api-utils/sign-in"; +import { RequireSignIn } from "../api-utils/access-level"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/favorites/favorite-button.tsx index 020081339..6578d7807 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/favorites/favorite-button.tsx @@ -21,7 +21,7 @@ import { getQueryUpdater } from "../common/utils"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; import { favoritesQueryKey } from "../queries"; import { useRefreshFavorites } from "../api-utils/refresh"; -import { RequireSignIn } from "../api-utils/sign-in"; +import { RequireSignIn } from "../api-utils/access-level"; enum Operation { ADD, diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index 382b29f62..f1bce29a6 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -46,7 +46,7 @@ import { import { getConfigurationKey, useUnitInfoQuery } from "../queries"; import { showErrorToast } from "../common/notifications"; import { SectionError } from "../app-common/app-zero-state"; -import { useIsSignedIn } from "../api-utils/sign-in"; +import { useIsSignedIn } from "../api-utils/access-level"; interface ConfigurationWrapperProps { configurationId: string; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index 9302bddff..a5c2d2201 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -1,5 +1,5 @@ import { useSearch } from "@tanstack/react-router"; -import { ReactNode, useCallback, useState } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { getFavoriteForInsertable, InsertableOut @@ -14,7 +14,8 @@ import { PreviewImageCard } from "./thumbnail"; import { FavoriteButton } from "../favorites/favorite-button"; import { NotificationAction, - renderNotification + renderNotification, + showInfoToast } from "../common/notifications"; import { MenuButton } from "../app-common/app-menu"; import { InsertableMenuItems } from "../cards/insertable-card"; @@ -24,7 +25,7 @@ import { ParameterValues } from "../../shared/configuration-models"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; -import { RequireSignIn } from "../api-utils/sign-in"; +import { useIsSignedIn } from "../api-utils/access-level"; interface OpenInsertMenuProps { insertable: InsertableOut; @@ -65,11 +66,21 @@ interface InsertMenuContentProps { function InsertMenuContent(props: InsertMenuContentProps): ReactNode { const { insertable, onInsert } = props; const favorites = useFavoritesQuery().data?.favorites; + const isSignedIn = useIsSignedIn(); const [configuration, setConfiguration] = useState< ParameterValues | undefined >(props.defaultConfiguration); + useEffect(() => { + if (!isSignedIn) { + showInfoToast( + "Sign in to Onshape to see the configuration preview.", + "sign-in-preview" + ); + } + }, [isSignedIn]); + if (!favorites) { return null; } @@ -112,14 +123,12 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { /> - - - + ); @@ -139,6 +148,8 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { const { insertable, configuration, isFavorite, onInsert } = props; const search = useSearch({ from: "/app" }); + // Inserting needs Onshape; the button stays visible but disabled otherwise. + const isSignedIn = useIsSignedIn(); const insertMutation = useInsertMutation(insertable, configuration, { isFavorite }); @@ -170,6 +181,7 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { ); diff --git a/src/frontend/cards/card-components.tsx b/src/frontend/cards/card-components.tsx index 8b9b46caf..05880902e 100644 --- a/src/frontend/cards/card-components.tsx +++ b/src/frontend/cards/card-components.tsx @@ -26,7 +26,6 @@ import { ThumbnailUrls } from "../../shared/types"; import { ParameterValues } from "../../shared/configuration-models"; import { useSearch } from "@tanstack/react-router"; import { RequireAccessLevel } from "../api-utils/access-level"; -import { RequireSignIn } from "../api-utils/access-level"; import { useReloadThumbnailMutation } from "./card-hooks"; interface OpenDocumentItemsProps { @@ -94,7 +93,7 @@ export function QuickInsertItems(props: QuickInsertItemProps) { search.elementType === ElementType.ASSEMBLY; return ( - + <> {supportsFasten && ( } @@ -109,7 +108,7 @@ export function QuickInsertItems(props: QuickInsertItemProps) { > Quick insert - + ); } diff --git a/src/frontend/cards/insertable-card.tsx b/src/frontend/cards/insertable-card.tsx index 5f61b7c65..9e710df4b 100644 --- a/src/frontend/cards/insertable-card.tsx +++ b/src/frontend/cards/insertable-card.tsx @@ -26,6 +26,7 @@ import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { openInsertMenu } from "../insert/insert-menu"; import { useFavoritesQuery } from "../queries"; import { RequireSignIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; interface InsertableCardProps extends PropsWithChildren { insertable: InsertableOut; @@ -86,7 +87,12 @@ export function InsertableCard(props: InsertableCardProps): ReactNode { /> } rightSection={ - + + + } menuItems={ - {!inInsertMenu && ( - + {!inInsertMenu && isConnected && ( + <> - + )} - - + + + + diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/favorites/favorite-button.tsx index 6578d7807..dc437329a 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/favorites/favorite-button.tsx @@ -21,7 +21,6 @@ import { getQueryUpdater } from "../common/utils"; import { toLibraryPath, useLibraryId } from "../api-utils/library"; import { favoritesQueryKey } from "../queries"; import { useRefreshFavorites } from "../api-utils/refresh"; -import { RequireSignIn } from "../api-utils/access-level"; enum Operation { ADD, @@ -123,22 +122,20 @@ export function FavoriteButton(props: FavoriteButtonProps): ReactNode { const operation = isFavorite ? Operation.REMOVE : Operation.ADD; return ( - - { - event.stopPropagation(); - const favoriteId = favorite?.id ?? crypto.randomUUID(); - mutation.mutate({ operation, insertable, favoriteId }); - }} - title={operation === Operation.ADD ? "Favorite" : "Unfavorite"} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {favoriteIcon} - - + { + event.stopPropagation(); + const favoriteId = favorite?.id ?? crypto.randomUUID(); + mutation.mutate({ operation, insertable, favoriteId }); + }} + title={operation === Operation.ADD ? "Favorite" : "Unfavorite"} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {favoriteIcon} + ); } @@ -157,24 +154,22 @@ export function FavoriteInsertableItem(props: FavoriteInsertableItemProps) { const mutation = useUpdateFavoritesMutation(); return ( - - - ) : ( - - ) - } - color={operation === Operation.ADD ? undefined : "red"} - onClick={() => { - const favoriteId = favorite?.id ?? crypto.randomUUID(); - mutation.mutate({ operation, insertable, favoriteId }); - }} - > - {operation === Operation.ADD ? "Favorite" : "Unfavorite"} - - + + ) : ( + + ) + } + color={operation === Operation.ADD ? undefined : "red"} + onClick={() => { + const favoriteId = favorite?.id ?? crypto.randomUUID(); + mutation.mutate({ operation, insertable, favoriteId }); + }} + > + {operation === Operation.ADD ? "Favorite" : "Unfavorite"} + ); } diff --git a/src/frontend/favorites/favorite-card.tsx b/src/frontend/favorites/favorite-card.tsx index 4b98e8a69..e5a1132ac 100644 --- a/src/frontend/favorites/favorite-card.tsx +++ b/src/frontend/favorites/favorite-card.tsx @@ -20,6 +20,7 @@ import { useIsInsertableHidden } from "../cards/card-hooks"; import { useIsAssemblyInPartStudio } from "../insert/insert-hooks"; import { ChangeOrderItems } from "../common/change-order"; import { useUiState } from "../api-utils/ui-state"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; import { openCannotDeriveAssemblyAlert, openCannotEditDefaultConfigurationAlert, @@ -96,18 +97,23 @@ function FavoriteMenuItems(props: FavoriteMenuItemsProps): ReactNode { const { insertable, favorite } = props; const uiState = useUiState()[0]; + const isConnected = useIsConnectedToOnshape(); const setFavoriteOrderMutation = useSetFavoriteOrderMutation(); const favoriteOrder = useFavoritesQuery().data?.favoriteOrder ?? []; return ( <> - - + {isConnected && ( + <> + + + + )} } onClick={() => { diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index f1bce29a6..f30ecee7d 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -28,7 +28,8 @@ import { StringParameter, QuantityParameter, UnitInfo, - EnumOption + EnumOption, + DEFAULT_UNIT_INFO } from "../../shared/configuration-models"; import { QuantityType, Unit } from "../../shared/configuration-enums"; import { @@ -46,7 +47,7 @@ import { import { getConfigurationKey, useUnitInfoQuery } from "../queries"; import { showErrorToast } from "../common/notifications"; import { SectionError } from "../app-common/app-zero-state"; -import { useIsSignedIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; interface ConfigurationWrapperProps { configurationId: string; @@ -71,11 +72,11 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { }); const search = useSearch({ from: "/app" }); - // Document units need Onshape; when not signed in, skip the fetch and let - // each quantity render in its own unit (see getEvaluateOptions). - const isSignedIn = useIsSignedIn(); - const unitInfoQuery = useUnitInfoQuery(search, isSignedIn); - const unitInfo = isSignedIn ? unitInfoQuery.data : undefined; + // Units come from the current document; use the default placeholder when not + // connected to one, so the dialog still renders. + const isConnected = useIsConnectedToOnshape(); + const unitInfoQuery = useUnitInfoQuery(search, isConnected); + const unitInfo = unitInfoQuery.data ?? DEFAULT_UNIT_INFO; useEffect(() => { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -93,11 +94,7 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { setConfiguration(defaultConfiguration); }, [query.data, configuration, setConfiguration]); - if ( - query.isPending || - (isSignedIn && unitInfoQuery.isPending) || - !configuration - ) { + if (query.isPending || !configuration) { return (
@@ -105,8 +102,6 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { ); } else if (query.isError) { return ; - } else if (isSignedIn && unitInfoQuery.isError) { - return ; } return ( @@ -123,7 +118,7 @@ interface ConfigurationParameterProps { configurationResult: ConfigurationResult; configuration: ParameterValues; setConfiguration: Dispatch; - unitInfo?: UnitInfo; + unitInfo: UnitInfo; } function ConfigurationParameters(props: ConfigurationParameterProps) { @@ -167,7 +162,7 @@ interface ParameterProps { onValueChange: (newValue: string | undefined) => void; configuration: ParameterValues; parameters: ConfigurationParameter[]; - unitInfo?: UnitInfo; + unitInfo: UnitInfo; } function ParameterInput( @@ -360,12 +355,9 @@ function StringInput(props: ParameterProps): ReactNode { ); } -/** Fallback display precision when document units aren't available (not signed in). */ -const DEFAULT_QUANTITY_PRECISION = 3; - function getEvaluateOptions( parameter: QuantityParameter, - unitInfo?: UnitInfo + unitInfo: UnitInfo ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { @@ -375,24 +367,21 @@ function getEvaluateOptions( if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: - unitInfo?.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo?.lengthUnit ?? parameter.unit, + displayPrecision: unitInfo.lengthPrecision, + displayUnit: unitInfo.lengthUnit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: - unitInfo?.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, - displayUnit: unitInfo?.angleUnit ?? parameter.unit, + displayPrecision: unitInfo.anglePrecision, + displayUnit: unitInfo.angleUnit, ...minAndMax }; } else if (quantityType == QuantityType.REAL) { return { quantityType, - displayPrecision: - unitInfo?.realPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayPrecision: unitInfo.realPrecision, displayUnit: Unit.UNITLESS, ...minAndMax }; diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index a5c2d2201..a944e699b 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -14,8 +14,7 @@ import { PreviewImageCard } from "./thumbnail"; import { FavoriteButton } from "../favorites/favorite-button"; import { NotificationAction, - renderNotification, - showInfoToast + renderNotification } from "../common/notifications"; import { MenuButton } from "../app-common/app-menu"; import { InsertableMenuItems } from "../cards/insertable-card"; @@ -25,7 +24,9 @@ import { ParameterValues } from "../../shared/configuration-models"; import { useFavoritesQuery } from "../queries"; import { useUiState } from "../api-utils/ui-state"; import { notifications } from "@mantine/notifications"; -import { useIsSignedIn } from "../api-utils/access-level"; +import { RequireSignIn, useIsSignedIn } from "../api-utils/access-level"; +import { useIsConnectedToOnshape } from "../api-utils/onshape-params"; +import { startSignIn } from "../api-utils/sign-in"; interface OpenInsertMenuProps { insertable: InsertableOut; @@ -74,10 +75,7 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { useEffect(() => { if (!isSignedIn) { - showInfoToast( - "Sign in to Onshape to see the configuration preview.", - "sign-in-preview" - ); + showSignInPreviewToast(); } }, [isSignedIn]); @@ -110,10 +108,12 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { {parameters} - + + + } loading={isLoadingConfiguration || insertMutation.isPending} - disabled={!isSignedIn} + disabled={!isConnected} onClick={handleClick} > {search.elementType === ElementType.ASSEMBLY @@ -192,6 +192,20 @@ function InsertButtons(props: InsertButtonsProps): ReactNode { ); } +/** Prompts a not-signed-in viewer that the live preview needs Onshape. */ +function showSignInPreviewToast() { + notifications.hide("sign-in-preview"); + notifications.show({ + id: "sign-in-preview", + color: "blue", + icon: , + message: renderNotification( + "Sign in to Onshape to see the configuration preview.", + { text: "Sign in", onClick: startSignIn } + ) + }); +} + function showRestoreToast( insertable: InsertableOut, configuration?: ParameterValues diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index bf93d3495..1c424bb04 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -15,7 +15,10 @@ import { import { LibraryId } from "../shared/types"; import { type AccessData } from "../shared/types"; import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { type UnitInfo } from "../shared/configuration-models"; +import { + DEFAULT_UNIT_INFO, + type UnitInfo +} from "../shared/configuration-models"; import MiniSearch from "minisearch"; import { SEARCH_OPTIONS } from "../shared/search"; import { InstancePath } from "../shared/onshape-path"; @@ -98,8 +101,9 @@ export function getAccessDataQuery() { } /** - * Returns information needed to format unit expressions in the Insert dialog. - * Hits Onshape, so it must be disabled when the caller isn't signed in. + * Returns the current document's units for the Insert dialog. Hits Onshape, so + * it's disabled when not connected to a document; a default placeholder lets the + * dialog render immediately either way. */ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { return useQuery({ @@ -112,7 +116,8 @@ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { instanceType: instancePath.instanceType } }), - enabled + enabled, + placeholderData: DEFAULT_UNIT_INFO }); } diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx index a79895504..76462c099 100644 --- a/src/frontend/routes/index.tsx +++ b/src/frontend/routes/index.tsx @@ -1,17 +1,16 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { DEFAULT_LIBRARY_ID, DEFAULT_SETTINGS } from "../../shared/types"; import { readLocalSettings } from "../settings/local-settings"; import { RootAppError } from "../app/root-error"; -// Direct entry for not-signed-in users (Onshape launches via `/init` instead), -// seeding the library and theme from locally-saved settings. +// Direct entry for a user opening the app outside Onshape. Onshape's own launch +// is handled server-side, so it never reaches this route. export const Route = createFileRoute("/")({ beforeLoad: () => { - const local = readLocalSettings(); + const { libraryId, theme } = readLocalSettings(); throw redirect({ to: "/app/library/$libraryId", - params: { libraryId: local.libraryId ?? DEFAULT_LIBRARY_ID }, - search: { theme: local.theme ?? DEFAULT_SETTINGS.theme } + params: { libraryId }, + search: { theme } }); }, errorComponent: RootAppError diff --git a/src/frontend/settings/local-settings.ts b/src/frontend/settings/local-settings.ts index 72bb00492..2f8fd6d88 100644 --- a/src/frontend/settings/local-settings.ts +++ b/src/frontend/settings/local-settings.ts @@ -1,9 +1,14 @@ -import { type SettingsUpdate } from "../../shared/types"; +import { + DEFAULT_LIBRARY_ID, + DEFAULT_SETTINGS, + type LibraryId, + type SettingsUpdate, + type Theme +} from "../../shared/types"; const SETTINGS_STORAGE_KEY = "frc-design-app-settings"; -/** Reads locally-persisted settings, used when not signed in. */ -export function readLocalSettings(): SettingsUpdate { +function readStored(): SettingsUpdate { try { const raw = localStorage.getItem(SETTINGS_STORAGE_KEY); return raw ? (JSON.parse(raw) as SettingsUpdate) : {}; @@ -12,10 +17,19 @@ export function readLocalSettings(): SettingsUpdate { } } +/** Locally-persisted settings (used when not signed in), with defaults filled. */ +export function readLocalSettings(): { theme: Theme; libraryId: LibraryId } { + const stored = readStored(); + return { + theme: stored.theme ?? DEFAULT_SETTINGS.theme, + libraryId: stored.libraryId ?? DEFAULT_LIBRARY_ID + }; +} + /** Merges and persists settings locally, used when not signed in. */ export function writeLocalSettings(newSettings: SettingsUpdate): void { try { - const merged = { ...readLocalSettings(), ...newSettings }; + const merged = { ...readStored(), ...newSettings }; localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(merged)); } catch { // Ignore storage failures (e.g. private browsing). diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index 866c5e36a..9402e6997 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -150,3 +150,12 @@ export interface UnitInfo { anglePrecision: number; realPrecision: number; } + +/** Shown while the document's units load, or when not connected to a document. */ +export const DEFAULT_UNIT_INFO: UnitInfo = { + lengthUnit: Unit.MILLIMETER, + angleUnit: Unit.DEGREE, + lengthPrecision: 3, + anglePrecision: 3, + realPrecision: 3 +}; From f1f4c5e6d1761dd9c146e8b9899689b713e7ffb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 21:07:10 +0000 Subject: [PATCH 08/11] Make UnitInfo fields optional, fall back to per-parameter units Quantity parameters render in their own default unit whenever the document's units aren't available (not connected to a document, or units no longer fetched), instead of a global mm/deg placeholder. Making the UnitInfo fields optional lets this fallback be the natural path and eases removing custom units later. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HFS93Qu7RahhGxZmCS78MA --- src/frontend/insert/configurations.tsx | 33 +++++++++++++++----------- src/frontend/queries.ts | 12 ++++------ src/shared/configuration-models.ts | 26 +++++++++----------- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/frontend/insert/configurations.tsx b/src/frontend/insert/configurations.tsx index f30ecee7d..7026036af 100644 --- a/src/frontend/insert/configurations.tsx +++ b/src/frontend/insert/configurations.tsx @@ -28,8 +28,7 @@ import { StringParameter, QuantityParameter, UnitInfo, - EnumOption, - DEFAULT_UNIT_INFO + EnumOption } from "../../shared/configuration-models"; import { QuantityType, Unit } from "../../shared/configuration-enums"; import { @@ -72,11 +71,10 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) { }); const search = useSearch({ from: "/app" }); - // Units come from the current document; use the default placeholder when not - // connected to one, so the dialog still renders. + // Units come from the current document; absent when not connected to one, in + // which case each quantity renders in its own unit (see getEvaluateOptions). const isConnected = useIsConnectedToOnshape(); - const unitInfoQuery = useUnitInfoQuery(search, isConnected); - const unitInfo = unitInfoQuery.data ?? DEFAULT_UNIT_INFO; + const unitInfo = useUnitInfoQuery(search, isConnected).data; useEffect(() => { // Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior @@ -118,7 +116,7 @@ interface ConfigurationParameterProps { configurationResult: ConfigurationResult; configuration: ParameterValues; setConfiguration: Dispatch; - unitInfo: UnitInfo; + unitInfo?: UnitInfo; } function ConfigurationParameters(props: ConfigurationParameterProps) { @@ -162,7 +160,7 @@ interface ParameterProps { onValueChange: (newValue: string | undefined) => void; configuration: ParameterValues; parameters: ConfigurationParameter[]; - unitInfo: UnitInfo; + unitInfo?: UnitInfo; } function ParameterInput( @@ -355,33 +353,40 @@ function StringInput(props: ParameterProps): ReactNode { ); } +/** Display precision used when the document's units aren't available. */ +const DEFAULT_QUANTITY_PRECISION = 3; + function getEvaluateOptions( parameter: QuantityParameter, - unitInfo: UnitInfo + unitInfo?: UnitInfo ): EvaluateOptions { const quantityType = parameter.quantityType; const minAndMax = { min: valueWithUnits(parameter.min, parameter.unit), max: valueWithUnits(parameter.max, parameter.unit) }; + // Fall back to the parameter's own unit when the document's isn't available. if (quantityType === QuantityType.LENGTH) { return { quantityType, - displayPrecision: unitInfo.lengthPrecision, - displayUnit: unitInfo.lengthUnit, + displayPrecision: + unitInfo?.lengthPrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.lengthUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType === QuantityType.ANGLE) { return { quantityType, - displayPrecision: unitInfo.anglePrecision, - displayUnit: unitInfo.angleUnit, + displayPrecision: + unitInfo?.anglePrecision ?? DEFAULT_QUANTITY_PRECISION, + displayUnit: unitInfo?.angleUnit ?? parameter.unit, ...minAndMax }; } else if (quantityType == QuantityType.REAL) { return { quantityType, - displayPrecision: unitInfo.realPrecision, + displayPrecision: + unitInfo?.realPrecision ?? DEFAULT_QUANTITY_PRECISION, displayUnit: Unit.UNITLESS, ...minAndMax }; diff --git a/src/frontend/queries.ts b/src/frontend/queries.ts index 1c424bb04..d542df661 100644 --- a/src/frontend/queries.ts +++ b/src/frontend/queries.ts @@ -15,10 +15,7 @@ import { import { LibraryId } from "../shared/types"; import { type AccessData } from "../shared/types"; import { toLibraryPath, useLibraryId } from "./api-utils/library"; -import { - DEFAULT_UNIT_INFO, - type UnitInfo -} from "../shared/configuration-models"; +import { type UnitInfo } from "../shared/configuration-models"; import MiniSearch from "minisearch"; import { SEARCH_OPTIONS } from "../shared/search"; import { InstancePath } from "../shared/onshape-path"; @@ -102,8 +99,8 @@ export function getAccessDataQuery() { /** * Returns the current document's units for the Insert dialog. Hits Onshape, so - * it's disabled when not connected to a document; a default placeholder lets the - * dialog render immediately either way. + * it's disabled when not connected to a document; each quantity then falls back + * to its own default unit. */ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { return useQuery({ @@ -116,8 +113,7 @@ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) { instanceType: instancePath.instanceType } }), - enabled, - placeholderData: DEFAULT_UNIT_INFO + enabled }); } diff --git a/src/shared/configuration-models.ts b/src/shared/configuration-models.ts index 9402e6997..fa248e4d2 100644 --- a/src/shared/configuration-models.ts +++ b/src/shared/configuration-models.ts @@ -143,19 +143,15 @@ export interface Configuration { /** * Custom data collected from the current tab the user has open. */ +/** + * The current document's units. Fields are optional: when a unit is absent (not + * connected to a document, or units no longer fetched) each quantity falls back + * to its own default unit. + */ export interface UnitInfo { - angleUnit: Unit; - lengthUnit: Unit; - lengthPrecision: number; - anglePrecision: number; - realPrecision: number; -} - -/** Shown while the document's units load, or when not connected to a document. */ -export const DEFAULT_UNIT_INFO: UnitInfo = { - lengthUnit: Unit.MILLIMETER, - angleUnit: Unit.DEGREE, - lengthPrecision: 3, - anglePrecision: 3, - realPrecision: 3 -}; + angleUnit?: Unit; + lengthUnit?: Unit; + lengthPrecision?: number; + anglePrecision?: number; + realPrecision?: number; +} From f496e77440d2d2d8e8c66d59e213f74d9d1e2de5 Mon Sep 17 00:00:00 2001 From: Alex Kempen Date: Mon, 17 Aug 2026 20:22:44 -0500 Subject: [PATCH 09/11] Regen route tree --- src/frontend/routeTree.gen.ts | 448 +++++++++++++++++----------------- 1 file changed, 224 insertions(+), 224 deletions(-) diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index 9d46791e8..dea2f3a0d 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -8,269 +8,269 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as AppRouteRouteImport } from "./routes/app/route"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as PagesSafariErrorRouteImport } from "./routes/_pages/safari-error"; -import { Route as PagesLicenseRouteImport } from "./routes/_pages/license"; -import { Route as PagesGrantDeniedRouteImport } from "./routes/_pages/grant-denied"; -import { Route as PagesCookieErrorRouteImport } from "./routes/_pages/cookie-error"; -import { Route as PagesBetaCompleteRouteImport } from "./routes/_pages/beta-complete"; -import { Route as AppLibraryLibraryIdRouteRouteImport } from "./routes/app/library/$libraryId/route"; -import { Route as AppLibraryLibraryIdIndexRouteImport } from "./routes/app/library/$libraryId/index"; -import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from "./routes/app/library/$libraryId/groups/$groupId"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as AppRouteRouteImport } from './routes/app/route' +import { Route as IndexRouteImport } from './routes/index' +import { Route as PagesSafariErrorRouteImport } from './routes/_pages/safari-error' +import { Route as PagesLicenseRouteImport } from './routes/_pages/license' +import { Route as PagesGrantDeniedRouteImport } from './routes/_pages/grant-denied' +import { Route as PagesCookieErrorRouteImport } from './routes/_pages/cookie-error' +import { Route as PagesBetaCompleteRouteImport } from './routes/_pages/beta-complete' +import { Route as AppLibraryLibraryIdRouteRouteImport } from './routes/app/library/$libraryId/route' +import { Route as AppLibraryLibraryIdIndexRouteImport } from './routes/app/library/$libraryId/index' +import { Route as AppLibraryLibraryIdGroupsGroupIdRouteImport } from './routes/app/library/$libraryId/groups/$groupId' const AppRouteRoute = AppRouteRouteImport.update({ - id: "/app", - path: "/app", - getParentRoute: () => rootRouteImport -} as any); + id: '/app', + path: '/app', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", - getParentRoute: () => rootRouteImport -} as any); + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) const PagesSafariErrorRoute = PagesSafariErrorRouteImport.update({ - id: "/_pages/safari-error", - path: "/safari-error", - getParentRoute: () => rootRouteImport -} as any); + id: '/_pages/safari-error', + path: '/safari-error', + getParentRoute: () => rootRouteImport, +} as any) const PagesLicenseRoute = PagesLicenseRouteImport.update({ - id: "/_pages/license", - path: "/license", - getParentRoute: () => rootRouteImport -} as any); + id: '/_pages/license', + path: '/license', + getParentRoute: () => rootRouteImport, +} as any) const PagesGrantDeniedRoute = PagesGrantDeniedRouteImport.update({ - id: "/_pages/grant-denied", - path: "/grant-denied", - getParentRoute: () => rootRouteImport -} as any); + id: '/_pages/grant-denied', + path: '/grant-denied', + getParentRoute: () => rootRouteImport, +} as any) const PagesCookieErrorRoute = PagesCookieErrorRouteImport.update({ - id: "/_pages/cookie-error", - path: "/cookie-error", - getParentRoute: () => rootRouteImport -} as any); + id: '/_pages/cookie-error', + path: '/cookie-error', + getParentRoute: () => rootRouteImport, +} as any) const PagesBetaCompleteRoute = PagesBetaCompleteRouteImport.update({ - id: "/_pages/beta-complete", - path: "/beta-complete", - getParentRoute: () => rootRouteImport -} as any); + id: '/_pages/beta-complete', + path: '/beta-complete', + getParentRoute: () => rootRouteImport, +} as any) const AppLibraryLibraryIdRouteRoute = - AppLibraryLibraryIdRouteRouteImport.update({ - id: "/library/$libraryId", - path: "/library/$libraryId", - getParentRoute: () => AppRouteRoute - } as any); + AppLibraryLibraryIdRouteRouteImport.update({ + id: '/library/$libraryId', + path: '/library/$libraryId', + getParentRoute: () => AppRouteRoute, + } as any) const AppLibraryLibraryIdIndexRoute = - AppLibraryLibraryIdIndexRouteImport.update({ - id: "/", - path: "/", - getParentRoute: () => AppLibraryLibraryIdRouteRoute - } as any); + AppLibraryLibraryIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppLibraryLibraryIdRouteRoute, + } as any) const AppLibraryLibraryIdGroupsGroupIdRoute = - AppLibraryLibraryIdGroupsGroupIdRouteImport.update({ - id: "/groups/$groupId", - path: "/groups/$groupId", - getParentRoute: () => AppLibraryLibraryIdRouteRoute - } as any); + AppLibraryLibraryIdGroupsGroupIdRouteImport.update({ + id: '/groups/$groupId', + path: '/groups/$groupId', + getParentRoute: () => AppLibraryLibraryIdRouteRoute, + } as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/app": typeof AppRouteRouteWithChildren; - "/beta-complete": typeof PagesBetaCompleteRoute; - "/cookie-error": typeof PagesCookieErrorRoute; - "/grant-denied": typeof PagesGrantDeniedRoute; - "/license": typeof PagesLicenseRoute; - "/safari-error": typeof PagesSafariErrorRoute; - "/app/library/$libraryId": typeof AppLibraryLibraryIdRouteRouteWithChildren; - "/app/library/$libraryId/": typeof AppLibraryLibraryIdIndexRoute; - "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; + '/': typeof IndexRoute + '/app': typeof AppRouteRouteWithChildren + '/beta-complete': typeof PagesBetaCompleteRoute + '/cookie-error': typeof PagesCookieErrorRoute + '/grant-denied': typeof PagesGrantDeniedRoute + '/license': typeof PagesLicenseRoute + '/safari-error': typeof PagesSafariErrorRoute + '/app/library/$libraryId': typeof AppLibraryLibraryIdRouteRouteWithChildren + '/app/library/$libraryId/': typeof AppLibraryLibraryIdIndexRoute + '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/app": typeof AppRouteRouteWithChildren; - "/beta-complete": typeof PagesBetaCompleteRoute; - "/cookie-error": typeof PagesCookieErrorRoute; - "/grant-denied": typeof PagesGrantDeniedRoute; - "/license": typeof PagesLicenseRoute; - "/safari-error": typeof PagesSafariErrorRoute; - "/app/library/$libraryId": typeof AppLibraryLibraryIdIndexRoute; - "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; + '/': typeof IndexRoute + '/app': typeof AppRouteRouteWithChildren + '/beta-complete': typeof PagesBetaCompleteRoute + '/cookie-error': typeof PagesCookieErrorRoute + '/grant-denied': typeof PagesGrantDeniedRoute + '/license': typeof PagesLicenseRoute + '/safari-error': typeof PagesSafariErrorRoute + '/app/library/$libraryId': typeof AppLibraryLibraryIdIndexRoute + '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/app": typeof AppRouteRouteWithChildren; - "/_pages/beta-complete": typeof PagesBetaCompleteRoute; - "/_pages/cookie-error": typeof PagesCookieErrorRoute; - "/_pages/grant-denied": typeof PagesGrantDeniedRoute; - "/_pages/license": typeof PagesLicenseRoute; - "/_pages/safari-error": typeof PagesSafariErrorRoute; - "/app/library/$libraryId": typeof AppLibraryLibraryIdRouteRouteWithChildren; - "/app/library/$libraryId/": typeof AppLibraryLibraryIdIndexRoute; - "/app/library/$libraryId/groups/$groupId": typeof AppLibraryLibraryIdGroupsGroupIdRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/app': typeof AppRouteRouteWithChildren + '/_pages/beta-complete': typeof PagesBetaCompleteRoute + '/_pages/cookie-error': typeof PagesCookieErrorRoute + '/_pages/grant-denied': typeof PagesGrantDeniedRoute + '/_pages/license': typeof PagesLicenseRoute + '/_pages/safari-error': typeof PagesSafariErrorRoute + '/app/library/$libraryId': typeof AppLibraryLibraryIdRouteRouteWithChildren + '/app/library/$libraryId/': typeof AppLibraryLibraryIdIndexRoute + '/app/library/$libraryId/groups/$groupId': typeof AppLibraryLibraryIdGroupsGroupIdRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; - fullPaths: - | "/" - | "/app" - | "/beta-complete" - | "/cookie-error" - | "/grant-denied" - | "/license" - | "/safari-error" - | "/app/library/$libraryId" - | "/app/library/$libraryId/" - | "/app/library/$libraryId/groups/$groupId"; - fileRoutesByTo: FileRoutesByTo; - to: - | "/" - | "/app" - | "/beta-complete" - | "/cookie-error" - | "/grant-denied" - | "/license" - | "/safari-error" - | "/app/library/$libraryId" - | "/app/library/$libraryId/groups/$groupId"; - id: - | "__root__" - | "/" - | "/app" - | "/_pages/beta-complete" - | "/_pages/cookie-error" - | "/_pages/grant-denied" - | "/_pages/license" - | "/_pages/safari-error" - | "/app/library/$libraryId" - | "/app/library/$libraryId/" - | "/app/library/$libraryId/groups/$groupId"; - fileRoutesById: FileRoutesById; + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/app' + | '/beta-complete' + | '/cookie-error' + | '/grant-denied' + | '/license' + | '/safari-error' + | '/app/library/$libraryId' + | '/app/library/$libraryId/' + | '/app/library/$libraryId/groups/$groupId' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/app' + | '/beta-complete' + | '/cookie-error' + | '/grant-denied' + | '/license' + | '/safari-error' + | '/app/library/$libraryId' + | '/app/library/$libraryId/groups/$groupId' + id: + | '__root__' + | '/' + | '/app' + | '/_pages/beta-complete' + | '/_pages/cookie-error' + | '/_pages/grant-denied' + | '/_pages/license' + | '/_pages/safari-error' + | '/app/library/$libraryId' + | '/app/library/$libraryId/' + | '/app/library/$libraryId/groups/$groupId' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - AppRouteRoute: typeof AppRouteRouteWithChildren; - PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute; - PagesCookieErrorRoute: typeof PagesCookieErrorRoute; - PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute; - PagesLicenseRoute: typeof PagesLicenseRoute; - PagesSafariErrorRoute: typeof PagesSafariErrorRoute; + IndexRoute: typeof IndexRoute + AppRouteRoute: typeof AppRouteRouteWithChildren + PagesBetaCompleteRoute: typeof PagesBetaCompleteRoute + PagesCookieErrorRoute: typeof PagesCookieErrorRoute + PagesGrantDeniedRoute: typeof PagesGrantDeniedRoute + PagesLicenseRoute: typeof PagesLicenseRoute + PagesSafariErrorRoute: typeof PagesSafariErrorRoute } -declare module "@tanstack/react-router" { - interface FileRoutesByPath { - "/app": { - id: "/app"; - path: "/app"; - fullPath: "/app"; - preLoaderRoute: typeof AppRouteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_pages/safari-error": { - id: "/_pages/safari-error"; - path: "/safari-error"; - fullPath: "/safari-error"; - preLoaderRoute: typeof PagesSafariErrorRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_pages/license": { - id: "/_pages/license"; - path: "/license"; - fullPath: "/license"; - preLoaderRoute: typeof PagesLicenseRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_pages/grant-denied": { - id: "/_pages/grant-denied"; - path: "/grant-denied"; - fullPath: "/grant-denied"; - preLoaderRoute: typeof PagesGrantDeniedRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_pages/cookie-error": { - id: "/_pages/cookie-error"; - path: "/cookie-error"; - fullPath: "/cookie-error"; - preLoaderRoute: typeof PagesCookieErrorRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/_pages/beta-complete": { - id: "/_pages/beta-complete"; - path: "/beta-complete"; - fullPath: "/beta-complete"; - preLoaderRoute: typeof PagesBetaCompleteRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/app/library/$libraryId": { - id: "/app/library/$libraryId"; - path: "/library/$libraryId"; - fullPath: "/app/library/$libraryId"; - preLoaderRoute: typeof AppLibraryLibraryIdRouteRouteImport; - parentRoute: typeof AppRouteRoute; - }; - "/app/library/$libraryId/": { - id: "/app/library/$libraryId/"; - path: "/"; - fullPath: "/app/library/$libraryId/"; - preLoaderRoute: typeof AppLibraryLibraryIdIndexRouteImport; - parentRoute: typeof AppLibraryLibraryIdRouteRoute; - }; - "/app/library/$libraryId/groups/$groupId": { - id: "/app/library/$libraryId/groups/$groupId"; - path: "/groups/$groupId"; - fullPath: "/app/library/$libraryId/groups/$groupId"; - preLoaderRoute: typeof AppLibraryLibraryIdGroupsGroupIdRouteImport; - parentRoute: typeof AppLibraryLibraryIdRouteRoute; - }; +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/app': { + id: '/app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppRouteRouteImport + parentRoute: typeof rootRouteImport } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_pages/safari-error': { + id: '/_pages/safari-error' + path: '/safari-error' + fullPath: '/safari-error' + preLoaderRoute: typeof PagesSafariErrorRouteImport + parentRoute: typeof rootRouteImport + } + '/_pages/license': { + id: '/_pages/license' + path: '/license' + fullPath: '/license' + preLoaderRoute: typeof PagesLicenseRouteImport + parentRoute: typeof rootRouteImport + } + '/_pages/grant-denied': { + id: '/_pages/grant-denied' + path: '/grant-denied' + fullPath: '/grant-denied' + preLoaderRoute: typeof PagesGrantDeniedRouteImport + parentRoute: typeof rootRouteImport + } + '/_pages/cookie-error': { + id: '/_pages/cookie-error' + path: '/cookie-error' + fullPath: '/cookie-error' + preLoaderRoute: typeof PagesCookieErrorRouteImport + parentRoute: typeof rootRouteImport + } + '/_pages/beta-complete': { + id: '/_pages/beta-complete' + path: '/beta-complete' + fullPath: '/beta-complete' + preLoaderRoute: typeof PagesBetaCompleteRouteImport + parentRoute: typeof rootRouteImport + } + '/app/library/$libraryId': { + id: '/app/library/$libraryId' + path: '/library/$libraryId' + fullPath: '/app/library/$libraryId' + preLoaderRoute: typeof AppLibraryLibraryIdRouteRouteImport + parentRoute: typeof AppRouteRoute + } + '/app/library/$libraryId/': { + id: '/app/library/$libraryId/' + path: '/' + fullPath: '/app/library/$libraryId/' + preLoaderRoute: typeof AppLibraryLibraryIdIndexRouteImport + parentRoute: typeof AppLibraryLibraryIdRouteRoute + } + '/app/library/$libraryId/groups/$groupId': { + id: '/app/library/$libraryId/groups/$groupId' + path: '/groups/$groupId' + fullPath: '/app/library/$libraryId/groups/$groupId' + preLoaderRoute: typeof AppLibraryLibraryIdGroupsGroupIdRouteImport + parentRoute: typeof AppLibraryLibraryIdRouteRoute + } + } } interface AppLibraryLibraryIdRouteRouteChildren { - AppLibraryLibraryIdIndexRoute: typeof AppLibraryLibraryIdIndexRoute; - AppLibraryLibraryIdGroupsGroupIdRoute: typeof AppLibraryLibraryIdGroupsGroupIdRoute; + AppLibraryLibraryIdIndexRoute: typeof AppLibraryLibraryIdIndexRoute + AppLibraryLibraryIdGroupsGroupIdRoute: typeof AppLibraryLibraryIdGroupsGroupIdRoute } const AppLibraryLibraryIdRouteRouteChildren: AppLibraryLibraryIdRouteRouteChildren = - { - AppLibraryLibraryIdIndexRoute: AppLibraryLibraryIdIndexRoute, - AppLibraryLibraryIdGroupsGroupIdRoute: - AppLibraryLibraryIdGroupsGroupIdRoute - }; + { + AppLibraryLibraryIdIndexRoute: AppLibraryLibraryIdIndexRoute, + AppLibraryLibraryIdGroupsGroupIdRoute: + AppLibraryLibraryIdGroupsGroupIdRoute, + } const AppLibraryLibraryIdRouteRouteWithChildren = - AppLibraryLibraryIdRouteRoute._addFileChildren( - AppLibraryLibraryIdRouteRouteChildren - ); + AppLibraryLibraryIdRouteRoute._addFileChildren( + AppLibraryLibraryIdRouteRouteChildren, + ) interface AppRouteRouteChildren { - AppLibraryLibraryIdRouteRoute: typeof AppLibraryLibraryIdRouteRouteWithChildren; + AppLibraryLibraryIdRouteRoute: typeof AppLibraryLibraryIdRouteRouteWithChildren } const AppRouteRouteChildren: AppRouteRouteChildren = { - AppLibraryLibraryIdRouteRoute: AppLibraryLibraryIdRouteRouteWithChildren -}; + AppLibraryLibraryIdRouteRoute: AppLibraryLibraryIdRouteRouteWithChildren, +} const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( - AppRouteRouteChildren -); + AppRouteRouteChildren, +) const rootRouteChildren: RootRouteChildren = { - IndexRoute: IndexRoute, - AppRouteRoute: AppRouteRouteWithChildren, - PagesBetaCompleteRoute: PagesBetaCompleteRoute, - PagesCookieErrorRoute: PagesCookieErrorRoute, - PagesGrantDeniedRoute: PagesGrantDeniedRoute, - PagesLicenseRoute: PagesLicenseRoute, - PagesSafariErrorRoute: PagesSafariErrorRoute -}; + IndexRoute: IndexRoute, + AppRouteRoute: AppRouteRouteWithChildren, + PagesBetaCompleteRoute: PagesBetaCompleteRoute, + PagesCookieErrorRoute: PagesCookieErrorRoute, + PagesGrantDeniedRoute: PagesGrantDeniedRoute, + PagesLicenseRoute: PagesLicenseRoute, + PagesSafariErrorRoute: PagesSafariErrorRoute, +} export const routeTree = rootRouteImport - ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileChildren(rootRouteChildren) + ._addFileTypes() From b675e8731858dc0da0a05b33feceb458560c0224 Mon Sep 17 00:00:00 2001 From: Alex Kempen Date: Mon, 17 Aug 2026 21:30:24 -0500 Subject: [PATCH 10/11] Fix standalone mode bugs --- src/backend/routes/favorites.ts | 7 +++- src/frontend/api-utils/access-level.tsx | 41 ++++++++++++++++--- src/frontend/api-utils/library.ts | 10 ++++- src/frontend/api-utils/refresh.ts | 12 +----- src/frontend/api-utils/sign-in.ts | 40 ++++++++++++++++++ src/frontend/api-utils/ui-state.ts | 7 +++- src/frontend/app-common/app-menu.tsx | 16 ++++++-- src/frontend/app/app-navbar.tsx | 16 +++++--- src/frontend/cards/build-status.tsx | 2 +- src/frontend/common/style-constants.ts | 16 +++++--- src/frontend/favorites/favorite-button.tsx | 35 ++++++++++++---- src/frontend/insert/insert-menu.tsx | 11 +++-- src/frontend/insert/thumbnail.tsx | 8 +++- src/frontend/queries.ts | 27 +++++------- .../routes/app/library/$libraryId/route.tsx | 12 +++++- src/frontend/routes/app/route.tsx | 2 + src/frontend/settings/settings-menu.tsx | 23 +++++++---- src/frontend/settings/vendor-filters.tsx | 8 ++-- src/shared/types.ts | 14 +++++++ 19 files changed, 230 insertions(+), 77 deletions(-) diff --git a/src/backend/routes/favorites.ts b/src/backend/routes/favorites.ts index dde9f90ed..babf68654 100644 --- a/src/backend/routes/favorites.ts +++ b/src/backend/routes/favorites.ts @@ -10,8 +10,11 @@ import { requireSignInMiddleware } from "../sign-in-utils"; export const favoriteRoutes = getApp(); -// Favorites are per-user and require a signed-in Onshape session. -favoriteRoutes.use(requireSignInMiddleware); +// Favorites are per-user and require a signed-in Onshape session. Scoped by +// path: a bare use() would apply to every route mounted at /api, not just these. +favoriteRoutes.use("/favorites/*", requireSignInMiddleware); +favoriteRoutes.use("/favorite-order/*", requireSignInMiddleware); +favoriteRoutes.use("/default-configuration/*", requireSignInMiddleware); async function getFavorites( db: Db, diff --git a/src/frontend/api-utils/access-level.tsx b/src/frontend/api-utils/access-level.tsx index 838f723f2..967ea9d46 100644 --- a/src/frontend/api-utils/access-level.tsx +++ b/src/frontend/api-utils/access-level.tsx @@ -1,9 +1,11 @@ -import { PropsWithChildren } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { PropsWithChildren, useMemo } from "react"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { hasEditorAccess } from "../../shared/types"; import { hasAdminAccess } from "../../shared/types"; +import { isWithinAccessLevel } from "../../shared/types"; import { AccessLevel, type AccessData } from "../../shared/types"; -import { getAccessDataQuery } from "../queries"; +import { apiGet } from "./api"; +import { useUiState } from "./ui-state"; /** What an unresolved caller gets: the least the app can show anyone. */ const DEFAULT_ACCESS_DATA: AccessData = { @@ -12,9 +14,38 @@ const DEFAULT_ACCESS_DATA: AccessData = { signedIn: false }; -/** The caller's access, which nothing waits for — editor affordances appear late. */ +export function accessDataQueryKey() { + return ["access-data"]; +} + +/** The caller's access level, which gates editor-only affordances. */ +export function getAccessDataQuery() { + return queryOptions({ + queryKey: accessDataQueryKey(), + queryFn: () => apiGet("/access-data") + }); +} + +/** + * The caller's access, which nothing waits for — editor affordances appear late. + * The settings menu can view the app as a lower level (or a higher one, up to + * the granted max); that choice is local state, so refetching the query — which + * every navigation does — can't revert it. + */ export function useAccessData(): AccessData { - return useQuery(getAccessDataQuery()).data ?? DEFAULT_ACCESS_DATA; + const serverData = useQuery(getAccessDataQuery()).data; + const chosenLevel = useUiState()[0].accessLevel; + return useMemo(() => { + const accessData = serverData ?? DEFAULT_ACCESS_DATA; + // A stored choice can outlive the access that allowed it. + if ( + !chosenLevel || + !isWithinAccessLevel(chosenLevel, accessData.maxAccessLevel) + ) { + return accessData; + } + return { ...accessData, currentAccessLevel: chosenLevel }; + }, [serverData, chosenLevel]); } /** Whether the caller is signed in to Onshape (from access-data). */ diff --git a/src/frontend/api-utils/library.ts b/src/frontend/api-utils/library.ts index 7037b1862..411385a4e 100644 --- a/src/frontend/api-utils/library.ts +++ b/src/frontend/api-utils/library.ts @@ -1,9 +1,15 @@ import { useParams } from "@tanstack/react-router"; -import { LibraryId } from "../../shared/types"; +import { DEFAULT_LIBRARY_ID, LibraryId } from "../../shared/types"; /** Returns the library being displayed, which the url is the source of truth for. */ export function useLibraryId(): LibraryId { - return useParams({ from: "/app/library/$libraryId" }).libraryId; + // Callers can sit outside the library route — modals mount at the root and + // error components replace the match — so fall back instead of throwing. + const params = useParams({ + from: "/app/library/$libraryId", + shouldThrow: false + }); + return params?.libraryId ?? DEFAULT_LIBRARY_ID; } export function toLibraryPath(libraryId: LibraryId): string { diff --git a/src/frontend/api-utils/refresh.ts b/src/frontend/api-utils/refresh.ts index fdccfabc7..e8d0c46a3 100644 --- a/src/frontend/api-utils/refresh.ts +++ b/src/frontend/api-utils/refresh.ts @@ -3,15 +3,14 @@ import { useRouter } from "@tanstack/react-router"; import { queryClient } from "../query-client"; import { buildStatusQueryMatchKey, - accessDataQueryKey, favoritesQueryKey, libraryQueryMatchKey, libraryVersionQueryMatchKey, useJobStatusQuery } from "../queries"; +import { accessDataQueryKey } from "./access-level"; import { useLibraryId } from "./library"; -import { type AccessData, type LibraryId } from "../../shared/types"; -import { getQueryUpdater } from "../common/utils"; +import { type LibraryId } from "../../shared/types"; /** Refetches the current user's favorites, which aren't version-keyed. */ function refetchFavorites(libraryId: LibraryId): Promise { @@ -68,10 +67,3 @@ export function useJobStatus(): boolean { }, [running, refreshLibrary]); return running; } - -type AccessDataUpdate = (data: AccessData) => void; - -/** Optimistically patches the cached access data, which re-renders its readers. */ -export function updateAccessData(update: AccessDataUpdate): void { - queryClient.setQueryData(accessDataQueryKey(), getQueryUpdater(update)); -} diff --git a/src/frontend/api-utils/sign-in.ts b/src/frontend/api-utils/sign-in.ts index 1683ecb17..741d0471e 100644 --- a/src/frontend/api-utils/sign-in.ts +++ b/src/frontend/api-utils/sign-in.ts @@ -1,6 +1,46 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getAccessDataQuery } from "./access-level"; +import { showSuccessToast } from "../common/notifications"; + +const PENDING_SIGN_IN_KEY = "frc-design-app-pending-sign-in"; + /** Redirects to the Onshape OAuth flow, returning to the current location. */ export function startSignIn(): void { + // The round trip reloads the app, so leave a note to report the result on + // the way back in. + try { + sessionStorage.setItem(PENDING_SIGN_IN_KEY, "true"); + } catch { + // Ignore storage failures (e.g. private browsing). + } const redirectUrl = window.location.pathname + window.location.search; window.location.href = "/auth/sign-in?redirectUrl=" + encodeURIComponent(redirectUrl); } + +/** Whether this load followed a sign-in attempt, clearing the flag either way. */ +function consumePendingSignIn(): boolean { + try { + const pending = sessionStorage.getItem(PENDING_SIGN_IN_KEY) !== null; + sessionStorage.removeItem(PENDING_SIGN_IN_KEY); + return pending; + } catch { + return false; + } +} + +/** Confirms a sign-in once the caller lands back from Onshape. */ +export function useSignInToast(): void { + const accessData = useQuery(getAccessDataQuery()).data; + useEffect(() => { + // Wait for access data so a denied grant clears the flag rather than + // toasting on some later visit in the same tab. + if (!accessData || !consumePendingSignIn()) { + return; + } + if (accessData.signedIn) { + showSuccessToast("Signed in to Onshape."); + } + }, [accessData]); +} diff --git a/src/frontend/api-utils/ui-state.ts b/src/frontend/api-utils/ui-state.ts index 0db6282d9..b09057be6 100644 --- a/src/frontend/api-utils/ui-state.ts +++ b/src/frontend/api-utils/ui-state.ts @@ -1,11 +1,12 @@ import { useSyncExternalStore } from "react"; import * as z from "zod"; -import { Vendor } from "../../shared/types"; +import { AccessLevel, Vendor } from "../../shared/types"; // Increment this when a breaking change is made to the schema const LATEST_VERSION = 3; const VendorType = z.enum(Object.values(Vendor)); +const AccessLevelType = z.enum(Object.values(AccessLevel)); const UiStateSchema = z.object({ version: z.number().default(1), // We can't default the parsed version to LATEST_VERSION because of old versions floating around @@ -14,7 +15,9 @@ const UiStateSchema = z.object({ vendorFilters: z.array(VendorType).optional(), searchQuery: z.string().default(""), openGroupId: z.string().optional(), - fasten: z.boolean().default(true) + fasten: z.boolean().default(true), + /** The access level to view the app as; absent means the granted default. */ + accessLevel: AccessLevelType.optional() }); type UiState = z.infer; diff --git a/src/frontend/app-common/app-menu.tsx b/src/frontend/app-common/app-menu.tsx index c634db1fb..e3795281d 100644 --- a/src/frontend/app-common/app-menu.tsx +++ b/src/frontend/app-common/app-menu.tsx @@ -63,16 +63,26 @@ export function AppContextMenu(props: AppContextMenuProps): ReactNode { * An explicit button which opens a menu with the given items. Used alongside * the right-click context menu so the menu is reachable without a right-click. */ -export function MenuButton(props: PropsWithChildren): ReactNode { +interface MenuButtonProps extends PropsWithChildren { + /** + * Sizes the button to sit beside a full-height button rather than in a card row. + * @default false + */ + large?: boolean; +} + +export function MenuButton(props: MenuButtonProps): ReactNode { + const { large, children } = props; return ( - + e.stopPropagation()} > - + ); diff --git a/src/frontend/app/app-navbar.tsx b/src/frontend/app/app-navbar.tsx index cbb1631bf..fd5928000 100644 --- a/src/frontend/app/app-navbar.tsx +++ b/src/frontend/app/app-navbar.tsx @@ -9,7 +9,7 @@ import { Tooltip } from "@mantine/core"; import { IconChevronDown, IconSearch, IconSettings } from "@tabler/icons-react"; -import { IconSize, PrimaryColor } from "../common/style-constants"; +import { HEADER_CONTROL_COLOR, IconSize } from "../common/style-constants"; import { ReactNode, RefObject, useRef } from "react"; import { useNavigate } from "@tanstack/react-router"; @@ -63,7 +63,11 @@ function SignInButton(): ReactNode { if (isSignedIn) return null; return ( - ); @@ -87,7 +91,7 @@ function RunningJobLoader(): ReactNode { withArrow label="The library is being loaded from Onshape in the background" > - + ); } @@ -158,11 +162,13 @@ export function SettingsButton() { return ( openSettingsMenu()} > - + ); } diff --git a/src/frontend/cards/build-status.tsx b/src/frontend/cards/build-status.tsx index 7b0aad755..a6be44103 100644 --- a/src/frontend/cards/build-status.tsx +++ b/src/frontend/cards/build-status.tsx @@ -209,7 +209,7 @@ export function useCloseBuildCard(): () => void { * given admin menu. Only rendered for editors and admins. */ export function BuildStatusBadge(props: BuildStatusBadgeProps): ReactNode { - // Gate first so only editors mount the child (and thus poll job status). + // Gate first so the card and its admin controls only exist for editors. return ( diff --git a/src/frontend/common/style-constants.ts b/src/frontend/common/style-constants.ts index 9ad83acad..cdd7ea36f 100644 --- a/src/frontend/common/style-constants.ts +++ b/src/frontend/common/style-constants.ts @@ -8,6 +8,8 @@ export enum IconSize { SMALL = 16, /** Buttons */ MEDIUM = 18, + /** Input-height controls, which sit next to full-height buttons */ + CONTROL = 24, /** In-line error states */ LARGE = 36, /** Full-page error states */ @@ -29,12 +31,6 @@ export const BORDER = "1px solid var(--mantine-color-default-border)"; /** The app's primary color as a filled background. */ export enum PrimaryColor { - /** - * The current primary color, typically white. - * - * Used to color the buttons that go over the colored app header. - */ - PRIMARY = "var(--mantine-primary-color)", /** * The current library color, e.g., green for FRCDesign. */ @@ -45,6 +41,14 @@ export enum PrimaryColor { CONTRAST = "var(--mantine-primary-color-contrast)" } +/** + * The `color` for Mantine controls sitting on the filled header. Hex, and not + * {@link PrimaryColor.CONTRAST} or `"white"`: Mantine derives each variant's + * border and hover tint by parsing `color`, and its parser understands only + * hex/rgb/hsl — a css var or a named color silently resolves to black. + */ +export const HEADER_CONTROL_COLOR = "#fff"; + /** Red used for heart/favorite icons. */ export const HeartIconColor = "var(--mantine-color-red-6)"; diff --git a/src/frontend/favorites/favorite-button.tsx b/src/frontend/favorites/favorite-button.tsx index dc437329a..d655fc28e 100644 --- a/src/frontend/favorites/favorite-button.tsx +++ b/src/frontend/favorites/favorite-button.tsx @@ -103,20 +103,30 @@ function useUpdateFavoritesMutation() { interface FavoriteButtonProps { favorite: Favorite | undefined; insertable: InsertableOut; + /** + * Sizes the button to sit beside a full-height button rather than in a card row. + * @default false + */ + large?: boolean; } export function FavoriteButton(props: FavoriteButtonProps): ReactNode { - const { favorite, insertable } = props; + const { favorite, insertable, large } = props; const isFavorite = favorite !== undefined; const [isHovered, setIsHovered] = useState(false); const mutation = useUpdateFavoritesMutation(); + const iconSize = large ? IconSize.CONTROL : IconSize.SMALL; let favoriteIcon; if (isHovered) { - favoriteIcon = isFavorite ? : ; + favoriteIcon = isFavorite ? ( + + ) : ( + + ); } else { - favoriteIcon = ; + favoriteIcon = ; } const operation = isFavorite ? Operation.REMOVE : Operation.ADD; @@ -125,6 +135,7 @@ export function FavoriteButton(props: FavoriteButtonProps): ReactNode { { event.stopPropagation(); const favoriteId = favorite?.id ?? crypto.randomUUID(); @@ -178,17 +189,27 @@ interface HeartIconProps { * @default true */ full?: boolean; + /** + * @default IconSize.SMALL + */ + size?: number; } export function HeartIcon(props: HeartIconProps): ReactNode { const full = props.full ?? true; + const size = props.size ?? IconSize.SMALL; return full ? ( - + ) : ( - + ); } -export function HeartBrokenIcon(): ReactNode { - return ; +export function HeartBrokenIcon(props: { size?: number }): ReactNode { + return ( + + ); } diff --git a/src/frontend/insert/insert-menu.tsx b/src/frontend/insert/insert-menu.tsx index a944e699b..9525ffab3 100644 --- a/src/frontend/insert/insert-menu.tsx +++ b/src/frontend/insert/insert-menu.tsx @@ -112,9 +112,10 @@ function InsertMenuContent(props: InsertMenuContentProps): ReactNode { - + {canFasten && ( @@ -181,7 +187,6 @@ function InsertButtons(props: InsertButtonsProps): ReactNode {