@ifc-lite/viewer 1.17.4 → 1.17.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -16
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +117 -0
- package/DESKTOP_CONTRACT_VERSION +1 -1
- package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
- package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
- package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
- package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
- package/dist/assets/ids-DQ5jY0E8.js +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
- package/dist/assets/index-_bfZsDCC.css +1 -0
- package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
- package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
- package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
- package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
- package/dist/index.html +8 -7
- package/index.html +1 -0
- package/package.json +7 -7
- package/src/App.tsx +16 -2
- package/src/components/viewer/CesiumOverlay.tsx +62 -19
- package/src/components/viewer/ChatPanel.tsx +195 -91
- package/src/components/viewer/MainToolbar.tsx +4 -3
- package/src/components/viewer/PropertiesPanel.tsx +16 -2
- package/src/components/viewer/SettingsPage.tsx +252 -101
- package/src/components/viewer/ThemeSwitch.tsx +63 -7
- package/src/components/viewer/ViewerLayout.tsx +1 -0
- package/src/components/viewer/Viewport.tsx +14 -2
- package/src/components/viewer/ViewportContainer.tsx +49 -64
- package/src/components/viewer/ViewportOverlays.tsx +5 -2
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
- package/src/components/viewer/chat/ModelSelector.tsx +90 -54
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
- package/src/components/viewer/properties/LocationMap.tsx +9 -7
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
- package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
- package/src/components/viewer/tools/SectionPanel.tsx +39 -18
- package/src/components/viewer/useAnimationLoop.ts +9 -1
- package/src/components/viewer/useRenderUpdates.ts +1 -1
- package/src/hooks/ids/idsDataAccessor.ts +60 -24
- package/src/hooks/ingest/viewerModelIngest.ts +7 -2
- package/src/hooks/useIfcFederation.ts +326 -71
- package/src/hooks/useIfcLoader.ts +1 -0
- package/src/hooks/useViewControls.ts +13 -5
- package/src/index.css +484 -10
- package/src/lib/desktop-entitlement.ts +2 -4
- package/src/lib/geo/cesium-bridge.ts +15 -7
- package/src/lib/geo/effective-georef.test.ts +73 -0
- package/src/lib/geo/effective-georef.ts +111 -0
- package/src/lib/geo/reproject.ts +105 -19
- package/src/lib/llm/byok-guard.test.ts +77 -0
- package/src/lib/llm/byok-guard.ts +39 -0
- package/src/lib/llm/free-models.test.ts +0 -6
- package/src/lib/llm/models.ts +104 -42
- package/src/lib/llm/stream-client.ts +74 -110
- package/src/lib/llm/stream-direct.test.ts +130 -0
- package/src/lib/llm/stream-direct.ts +316 -0
- package/src/lib/llm/types.ts +14 -2
- package/src/main.tsx +1 -10
- package/src/services/api-keys.ts +73 -0
- package/src/store/constants.ts +20 -2
- package/src/store/index.ts +12 -5
- package/src/store/slices/cesiumSlice.ts +5 -0
- package/src/store/slices/chatSlice.test.ts +6 -76
- package/src/store/slices/chatSlice.ts +17 -58
- package/src/store/slices/sectionSlice.test.ts +87 -7
- package/src/store/slices/sectionSlice.ts +151 -5
- package/src/store/slices/uiSlice.ts +28 -5
- package/src/store/types.ts +26 -0
- package/src/utils/nativeSpatialDataStore.ts +4 -1
- package/src/utils/viewportUtils.ts +7 -2
- package/src/vite-env.d.ts +0 -4
- package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
- package/dist/assets/ids-B4jTqB1O.js +0 -1
- package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
- package/dist/assets/index-DckuDqlv.css +0 -1
- package/src/components/viewer/UpgradePage.tsx +0 -71
- package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
- package/src/lib/llm/ClerkChatSync.tsx +0 -74
- package/src/lib/llm/clerk-auth.ts +0 -62
|
@@ -2,23 +2,28 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import { SignInButton, SignedIn, SignedOut, UserButton, useAuth, useUser } from '@clerk/clerk-react';
|
|
6
5
|
import {
|
|
7
6
|
ArrowLeft,
|
|
8
7
|
Bot,
|
|
9
8
|
Check,
|
|
9
|
+
ChevronDown,
|
|
10
|
+
ChevronUp,
|
|
10
11
|
Clock3,
|
|
11
12
|
Cloud,
|
|
12
13
|
CreditCard,
|
|
14
|
+
ExternalLink,
|
|
15
|
+
Eye,
|
|
16
|
+
EyeOff,
|
|
13
17
|
FolderOpen,
|
|
18
|
+
Key,
|
|
14
19
|
LayoutPanelTop,
|
|
15
20
|
Lock,
|
|
16
|
-
RefreshCw,
|
|
17
21
|
Settings2,
|
|
18
22
|
ShieldCheck,
|
|
23
|
+
Trash2,
|
|
19
24
|
WifiOff,
|
|
20
25
|
} from 'lucide-react';
|
|
21
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
26
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
22
27
|
import { Badge } from '@/components/ui/badge';
|
|
23
28
|
import { Button } from '@/components/ui/button';
|
|
24
29
|
import { Switch } from '@/components/ui/switch';
|
|
@@ -33,21 +38,25 @@ import {
|
|
|
33
38
|
isDesktopBillingEnforced,
|
|
34
39
|
type DesktopEntitlement,
|
|
35
40
|
} from '@/lib/desktop-product';
|
|
36
|
-
import { isClerkConfigured } from '@/lib/llm/clerk-auth';
|
|
37
41
|
import { navigateToPath } from '@/services/app-navigation';
|
|
38
|
-
import { requestDesktopEntitlementRefresh } from '@/lib/desktop/desktopEntitlementEvents';
|
|
39
42
|
import {
|
|
40
43
|
getDesktopPreferences,
|
|
41
44
|
subscribeDesktopPreferences,
|
|
42
45
|
updateDesktopPreferences,
|
|
43
46
|
} from '@/services/desktop-preferences';
|
|
47
|
+
import {
|
|
48
|
+
getApiKeys,
|
|
49
|
+
updateApiKeys,
|
|
50
|
+
clearApiKeys,
|
|
51
|
+
subscribeApiKeys,
|
|
52
|
+
type ApiKeyConfig,
|
|
53
|
+
} from '@/services/api-keys';
|
|
44
54
|
|
|
45
55
|
export function SettingsPage() {
|
|
46
|
-
const clerkEnabled = isClerkConfigured();
|
|
47
56
|
const desktopEntitlement = useViewerStore((s) => s.desktopEntitlement);
|
|
48
57
|
const chatUsage = useViewerStore((s) => s.chatUsage);
|
|
49
58
|
const [preferences, setPreferences] = useState(() => getDesktopPreferences());
|
|
50
|
-
const [
|
|
59
|
+
const [apiKeys, setApiKeys] = useState(() => getApiKeys());
|
|
51
60
|
const returnTo = (() => {
|
|
52
61
|
const params = new URLSearchParams(window.location.search);
|
|
53
62
|
const candidate = params.get('returnTo');
|
|
@@ -56,6 +65,9 @@ export function SettingsPage() {
|
|
|
56
65
|
useEffect(() => subscribeDesktopPreferences(() => {
|
|
57
66
|
setPreferences(getDesktopPreferences());
|
|
58
67
|
}), []);
|
|
68
|
+
useEffect(() => subscribeApiKeys(() => {
|
|
69
|
+
setApiKeys(getApiKeys());
|
|
70
|
+
}), []);
|
|
59
71
|
|
|
60
72
|
const updatePreference = (updates: Partial<typeof preferences>) => {
|
|
61
73
|
setPreferences(updateDesktopPreferences(updates));
|
|
@@ -75,19 +87,6 @@ export function SettingsPage() {
|
|
|
75
87
|
return `${chatUsage.used}/${chatUsage.limit} ${unit} used. Resets ${resetLabel}.`;
|
|
76
88
|
}, [chatUsage]);
|
|
77
89
|
|
|
78
|
-
const handleRefreshAccount = async () => {
|
|
79
|
-
setIsRefreshingAccount(true);
|
|
80
|
-
try {
|
|
81
|
-
await requestDesktopEntitlementRefresh();
|
|
82
|
-
toast.success('Account status refreshed');
|
|
83
|
-
} catch (error) {
|
|
84
|
-
const message = error instanceof Error ? error.message : 'Could not refresh account status';
|
|
85
|
-
toast.error(message);
|
|
86
|
-
} finally {
|
|
87
|
-
setIsRefreshingAccount(false);
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
90
|
return (
|
|
92
91
|
<div className="min-h-screen bg-background text-foreground">
|
|
93
92
|
<div className="mx-auto w-full max-w-4xl px-6 py-8">
|
|
@@ -99,6 +98,9 @@ export function SettingsPage() {
|
|
|
99
98
|
</div>
|
|
100
99
|
|
|
101
100
|
<div className="space-y-6">
|
|
101
|
+
{/* API Keys Section */}
|
|
102
|
+
<ApiKeysSection apiKeys={apiKeys} />
|
|
103
|
+
|
|
102
104
|
<section className="rounded-lg border bg-card p-6 shadow-sm">
|
|
103
105
|
<div className="mb-5 flex items-center gap-3">
|
|
104
106
|
<ShieldCheck className="h-5 w-5" />
|
|
@@ -127,8 +129,8 @@ export function SettingsPage() {
|
|
|
127
129
|
icon={<CreditCard className="h-4 w-4" />}
|
|
128
130
|
/>
|
|
129
131
|
<InfoCard
|
|
130
|
-
title="AI Usage"
|
|
131
|
-
body={usageSummary ?? 'No AI usage data yet.
|
|
132
|
+
title="AI Usage (Free Tier)"
|
|
133
|
+
body={usageSummary ?? 'No AI usage data yet. Usage appears after the first chat message through the proxy.'}
|
|
132
134
|
icon={<Bot className="h-4 w-4" />}
|
|
133
135
|
/>
|
|
134
136
|
<InfoCard
|
|
@@ -142,24 +144,6 @@ export function SettingsPage() {
|
|
|
142
144
|
icon={<WifiOff className="h-4 w-4" />}
|
|
143
145
|
/>
|
|
144
146
|
</div>
|
|
145
|
-
|
|
146
|
-
{clerkEnabled ? (
|
|
147
|
-
<div className="flex flex-wrap gap-3">
|
|
148
|
-
<Button
|
|
149
|
-
variant="outline"
|
|
150
|
-
onClick={() => void handleRefreshAccount()}
|
|
151
|
-
disabled={isRefreshingAccount}
|
|
152
|
-
>
|
|
153
|
-
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshingAccount ? 'animate-spin' : ''}`} />
|
|
154
|
-
Refresh Account Status
|
|
155
|
-
</Button>
|
|
156
|
-
{!hasDesktopPro(desktopEntitlement) ? (
|
|
157
|
-
<Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
|
|
158
|
-
Upgrade to Pro
|
|
159
|
-
</Button>
|
|
160
|
-
) : null}
|
|
161
|
-
</div>
|
|
162
|
-
) : null}
|
|
163
147
|
</section>
|
|
164
148
|
|
|
165
149
|
<section className="rounded-lg border bg-card p-6 shadow-sm">
|
|
@@ -225,7 +209,7 @@ export function SettingsPage() {
|
|
|
225
209
|
<div>
|
|
226
210
|
<h2 className="text-xl font-semibold">Billing & Features</h2>
|
|
227
211
|
<p className="text-sm text-muted-foreground">
|
|
228
|
-
Desktop billing is app-wide. The viewer stays available on Free, while Pro unlocks advanced desktop features
|
|
212
|
+
Desktop billing is app-wide. The viewer stays available on Free, while Pro unlocks advanced desktop features.
|
|
229
213
|
</p>
|
|
230
214
|
</div>
|
|
231
215
|
</div>
|
|
@@ -235,9 +219,6 @@ export function SettingsPage() {
|
|
|
235
219
|
<div>
|
|
236
220
|
<div className="font-medium capitalize">{planTier} plan</div>
|
|
237
221
|
<p className="text-sm text-muted-foreground">{planSummary}</p>
|
|
238
|
-
<p className="text-sm text-muted-foreground">
|
|
239
|
-
Desktop caches the latest validated plan locally and stores the auth bearer in native secure storage when available.
|
|
240
|
-
</p>
|
|
241
222
|
</div>
|
|
242
223
|
{isDesktopBillingEnforced() && !hasDesktopPro(desktopEntitlement) && (
|
|
243
224
|
<Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
|
|
@@ -270,14 +251,6 @@ export function SettingsPage() {
|
|
|
270
251
|
</div>
|
|
271
252
|
))}
|
|
272
253
|
</div>
|
|
273
|
-
|
|
274
|
-
{!clerkEnabled ? (
|
|
275
|
-
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
|
276
|
-
Auth and billing are not configured in this build. Set `VITE_CLERK_PUBLISHABLE_KEY` to enable sign-in and subscription flows.
|
|
277
|
-
</div>
|
|
278
|
-
) : (
|
|
279
|
-
<SettingsAccountSection desktopEntitlement={desktopEntitlement} />
|
|
280
|
-
)}
|
|
281
254
|
</section>
|
|
282
255
|
|
|
283
256
|
<section className="rounded-lg border bg-card p-6 shadow-sm">
|
|
@@ -299,7 +272,7 @@ export function SettingsPage() {
|
|
|
299
272
|
/>
|
|
300
273
|
<InfoCard
|
|
301
274
|
title="Needs Network"
|
|
302
|
-
body="
|
|
275
|
+
body="AI assistant (free via proxy, or direct with your API key), live bSDD lookups, and billing sync require network access."
|
|
303
276
|
icon={<Cloud className="h-4 w-4" />}
|
|
304
277
|
/>
|
|
305
278
|
</div>
|
|
@@ -310,6 +283,231 @@ export function SettingsPage() {
|
|
|
310
283
|
);
|
|
311
284
|
}
|
|
312
285
|
|
|
286
|
+
// ── API Keys Section ──────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function ApiKeyInput({
|
|
289
|
+
id,
|
|
290
|
+
value,
|
|
291
|
+
onChange,
|
|
292
|
+
onSave,
|
|
293
|
+
placeholder,
|
|
294
|
+
isSaved,
|
|
295
|
+
}: {
|
|
296
|
+
id: string;
|
|
297
|
+
value: string;
|
|
298
|
+
onChange: (v: string) => void;
|
|
299
|
+
onSave: () => void;
|
|
300
|
+
placeholder: string;
|
|
301
|
+
isSaved: boolean;
|
|
302
|
+
}) {
|
|
303
|
+
const [show, setShow] = useState(false);
|
|
304
|
+
return (
|
|
305
|
+
<div className="flex gap-2">
|
|
306
|
+
<div className="relative flex-1">
|
|
307
|
+
<input
|
|
308
|
+
id={id}
|
|
309
|
+
type={show ? 'text' : 'password'}
|
|
310
|
+
value={value}
|
|
311
|
+
onChange={(e) => onChange(e.target.value)}
|
|
312
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && !isSaved) onSave(); }}
|
|
313
|
+
placeholder={placeholder}
|
|
314
|
+
autoComplete="off"
|
|
315
|
+
spellCheck={false}
|
|
316
|
+
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-8"
|
|
317
|
+
/>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
onClick={() => setShow(!show)}
|
|
321
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
322
|
+
aria-label={show ? 'Hide key' : 'Show key'}
|
|
323
|
+
>
|
|
324
|
+
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
<Button size="sm" onClick={onSave} disabled={isSaved}>
|
|
328
|
+
Save
|
|
329
|
+
</Button>
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ApiKeysSection({ apiKeys }: { apiKeys: ApiKeyConfig }) {
|
|
335
|
+
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropicKey);
|
|
336
|
+
const [openaiKey, setOpenaiKey] = useState(apiKeys.openaiKey);
|
|
337
|
+
const [showHowTo, setShowHowTo] = useState<'anthropic' | 'openai' | null>(null);
|
|
338
|
+
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
setAnthropicKey(apiKeys.anthropicKey);
|
|
341
|
+
setOpenaiKey(apiKeys.openaiKey);
|
|
342
|
+
}, [apiKeys.anthropicKey, apiKeys.openaiKey]);
|
|
343
|
+
|
|
344
|
+
const saveAnthropicKey = useCallback(() => {
|
|
345
|
+
updateApiKeys({ anthropicKey: anthropicKey.trim() });
|
|
346
|
+
toast.success(anthropicKey.trim() ? 'Anthropic API key saved' : 'Anthropic API key removed');
|
|
347
|
+
}, [anthropicKey]);
|
|
348
|
+
|
|
349
|
+
const saveOpenaiKey = useCallback(() => {
|
|
350
|
+
updateApiKeys({ openaiKey: openaiKey.trim() });
|
|
351
|
+
toast.success(openaiKey.trim() ? 'OpenAI API key saved' : 'OpenAI API key removed');
|
|
352
|
+
}, [openaiKey]);
|
|
353
|
+
|
|
354
|
+
const handleClearAll = useCallback(() => {
|
|
355
|
+
clearApiKeys();
|
|
356
|
+
setAnthropicKey('');
|
|
357
|
+
setOpenaiKey('');
|
|
358
|
+
toast.success('All API keys removed');
|
|
359
|
+
}, []);
|
|
360
|
+
|
|
361
|
+
const hasAnyKey = apiKeys.anthropicKey.length > 0 || apiKeys.openaiKey.length > 0;
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<section className="rounded-lg border bg-card p-6 shadow-sm">
|
|
365
|
+
<div className="mb-5 flex items-center gap-3">
|
|
366
|
+
<Key className="h-5 w-5" />
|
|
367
|
+
<div>
|
|
368
|
+
<h1 className="text-2xl font-semibold">API Keys</h1>
|
|
369
|
+
<p className="text-sm text-muted-foreground">
|
|
370
|
+
Bring your own Anthropic or OpenAI API key to unlock additional models.
|
|
371
|
+
You can configure one or both providers independently.
|
|
372
|
+
</p>
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
<div className="space-y-4">
|
|
377
|
+
{/* Anthropic */}
|
|
378
|
+
<div className="rounded-md border p-4">
|
|
379
|
+
<div className="mb-2 flex items-center justify-between">
|
|
380
|
+
<label className="text-sm font-medium" htmlFor="anthropic-key">Anthropic API Key</label>
|
|
381
|
+
{apiKeys.anthropicKey ? (
|
|
382
|
+
<Badge variant="default" className="text-[10px]">
|
|
383
|
+
<Check className="mr-1 h-3 w-3" />
|
|
384
|
+
Configured
|
|
385
|
+
</Badge>
|
|
386
|
+
) : (
|
|
387
|
+
<Badge variant="outline" className="text-[10px]">Not set</Badge>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
<p className="mb-2 text-xs text-muted-foreground">
|
|
391
|
+
Unlocks <strong>Claude Opus 4.6</strong>, <strong>Claude Sonnet 4.6</strong>, and <strong>Claude Haiku 4.5</strong>.
|
|
392
|
+
</p>
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
onClick={() => setShowHowTo(showHowTo === 'anthropic' ? null : 'anthropic')}
|
|
396
|
+
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
397
|
+
>
|
|
398
|
+
{showHowTo === 'anthropic' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
399
|
+
How to get an Anthropic API key
|
|
400
|
+
</button>
|
|
401
|
+
{showHowTo === 'anthropic' && (
|
|
402
|
+
<div className="mb-3 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1.5">
|
|
403
|
+
<p>1. Go to{' '}
|
|
404
|
+
<a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
|
|
405
|
+
console.anthropic.com/settings/keys <ExternalLink className="h-2.5 w-2.5" />
|
|
406
|
+
</a>
|
|
407
|
+
</p>
|
|
408
|
+
<p>2. Sign in or create an Anthropic account</p>
|
|
409
|
+
<p>3. Click <strong>Create Key</strong>, name it (e.g. "ifc-lite")</p>
|
|
410
|
+
<p>4. Copy the key (starts with <code className="bg-muted px-1 rounded">sk-ant-api03-...</code>)</p>
|
|
411
|
+
<p>5. Paste it below and click Save</p>
|
|
412
|
+
<p className="pt-1 text-muted-foreground/70">Anthropic offers $5 free credit on new accounts. After that, usage is pay-as-you-go on your Anthropic billing.</p>
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
<ApiKeyInput
|
|
416
|
+
id="anthropic-key"
|
|
417
|
+
value={anthropicKey}
|
|
418
|
+
onChange={setAnthropicKey}
|
|
419
|
+
onSave={saveAnthropicKey}
|
|
420
|
+
placeholder="sk-ant-api03-..."
|
|
421
|
+
isSaved={anthropicKey.trim() === apiKeys.anthropicKey}
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* OpenAI */}
|
|
426
|
+
<div className="rounded-md border p-4">
|
|
427
|
+
<div className="mb-2 flex items-center justify-between">
|
|
428
|
+
<label className="text-sm font-medium" htmlFor="openai-key">OpenAI API Key</label>
|
|
429
|
+
{apiKeys.openaiKey ? (
|
|
430
|
+
<Badge variant="default" className="text-[10px]">
|
|
431
|
+
<Check className="mr-1 h-3 w-3" />
|
|
432
|
+
Configured
|
|
433
|
+
</Badge>
|
|
434
|
+
) : (
|
|
435
|
+
<Badge variant="outline" className="text-[10px]">Not set</Badge>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
<p className="mb-2 text-xs text-muted-foreground">
|
|
439
|
+
Unlocks <strong>GPT-5.4</strong>, <strong>GPT-5.3 Codex</strong>, and <strong>GPT-5.4 Mini</strong>.
|
|
440
|
+
</p>
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
onClick={() => setShowHowTo(showHowTo === 'openai' ? null : 'openai')}
|
|
444
|
+
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
445
|
+
>
|
|
446
|
+
{showHowTo === 'openai' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
447
|
+
How to get an OpenAI API key
|
|
448
|
+
</button>
|
|
449
|
+
{showHowTo === 'openai' && (
|
|
450
|
+
<div className="mb-3 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1.5">
|
|
451
|
+
<p>1. Go to{' '}
|
|
452
|
+
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
|
|
453
|
+
platform.openai.com/api-keys <ExternalLink className="h-2.5 w-2.5" />
|
|
454
|
+
</a>
|
|
455
|
+
</p>
|
|
456
|
+
<p>2. Sign in or create an OpenAI account</p>
|
|
457
|
+
<p>3. Click <strong>Create new secret key</strong>, name it (e.g. "ifc-lite")</p>
|
|
458
|
+
<p>4. Copy the key (starts with <code className="bg-muted px-1 rounded">sk-...</code>)</p>
|
|
459
|
+
<p>5. Paste it below and click Save</p>
|
|
460
|
+
<p className="pt-1 text-muted-foreground/70">OpenAI requires prepaid credits or a payment method. Usage is billed to your OpenAI account.</p>
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
<ApiKeyInput
|
|
464
|
+
id="openai-key"
|
|
465
|
+
value={openaiKey}
|
|
466
|
+
onChange={setOpenaiKey}
|
|
467
|
+
onSave={saveOpenaiKey}
|
|
468
|
+
placeholder="sk-..."
|
|
469
|
+
isSaved={openaiKey.trim() === apiKeys.openaiKey}
|
|
470
|
+
/>
|
|
471
|
+
</div>
|
|
472
|
+
|
|
473
|
+
{hasAnyKey && (
|
|
474
|
+
<div className="flex justify-end">
|
|
475
|
+
<Button variant="outline" size="sm" onClick={handleClearAll}>
|
|
476
|
+
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
477
|
+
Remove all keys
|
|
478
|
+
</Button>
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
|
|
482
|
+
{/* Security & Privacy notice */}
|
|
483
|
+
<div className="rounded-md border border-dashed p-4 text-xs space-y-2">
|
|
484
|
+
<p className="font-medium text-foreground">Your API keys never leave your machine.</p>
|
|
485
|
+
<ul className="list-disc pl-4 space-y-1 text-muted-foreground">
|
|
486
|
+
<li>Keys are stored in your browser's <code className="bg-muted px-1 rounded">localStorage</code> and persist across page reloads.</li>
|
|
487
|
+
<li>When you use a BYOK model, requests go <strong>directly from your browser to the provider</strong> (Anthropic or OpenAI). They never pass through our servers.</li>
|
|
488
|
+
<li>Free models use our server proxy and do not require any API key.</li>
|
|
489
|
+
<li>Clearing your browser data or clicking "Remove all keys" above permanently deletes them.</li>
|
|
490
|
+
</ul>
|
|
491
|
+
<p className="text-muted-foreground pt-1">
|
|
492
|
+
<strong>Verify in your browser console:</strong> open DevTools (F12), go to the Console tab, and run:
|
|
493
|
+
</p>
|
|
494
|
+
<pre className="bg-muted rounded px-2 py-1.5 font-mono text-[11px] overflow-x-auto text-foreground">
|
|
495
|
+
{`JSON.parse(localStorage.getItem('ifc-lite:api-keys:v1') ?? '{}')`}
|
|
496
|
+
</pre>
|
|
497
|
+
<p className="text-muted-foreground">
|
|
498
|
+
This shows exactly what is stored. Only you can see it. To delete everything:
|
|
499
|
+
</p>
|
|
500
|
+
<pre className="bg-muted rounded px-2 py-1.5 font-mono text-[11px] overflow-x-auto text-foreground">
|
|
501
|
+
{`localStorage.removeItem('ifc-lite:api-keys:v1')`}
|
|
502
|
+
</pre>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
</section>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
313
511
|
function formatTimestamp(value: number | null): string {
|
|
314
512
|
if (!value) {
|
|
315
513
|
return 'Not validated yet';
|
|
@@ -342,7 +540,7 @@ function describeDesktopStatus(entitlement: DesktopEntitlement): string {
|
|
|
342
540
|
case 'signed_out':
|
|
343
541
|
return 'Signed out';
|
|
344
542
|
case 'anonymous':
|
|
345
|
-
return '
|
|
543
|
+
return 'No account configured';
|
|
346
544
|
default:
|
|
347
545
|
return entitlement.status;
|
|
348
546
|
}
|
|
@@ -381,50 +579,3 @@ function InfoCard({ title, body, icon }: { title: string; body: string; icon: Re
|
|
|
381
579
|
</div>
|
|
382
580
|
);
|
|
383
581
|
}
|
|
384
|
-
|
|
385
|
-
function SettingsAccountSection({ desktopEntitlement }: { desktopEntitlement: DesktopEntitlement }) {
|
|
386
|
-
const { isSignedIn } = useAuth();
|
|
387
|
-
const { user } = useUser();
|
|
388
|
-
const hasPro = hasDesktopPro(desktopEntitlement);
|
|
389
|
-
const statusLabel = describeDesktopStatus(desktopEntitlement);
|
|
390
|
-
|
|
391
|
-
return (
|
|
392
|
-
<div className="space-y-4">
|
|
393
|
-
<SignedOut>
|
|
394
|
-
<div className="rounded-md border p-4">
|
|
395
|
-
<p className="mb-3 text-sm text-muted-foreground">
|
|
396
|
-
Sign in to sync your desktop plan, subscription status, and AI usage limits across web and desktop.
|
|
397
|
-
</p>
|
|
398
|
-
<SignInButton mode="modal" forceRedirectUrl="/settings" fallbackRedirectUrl="/settings">
|
|
399
|
-
<Button>Sign in</Button>
|
|
400
|
-
</SignInButton>
|
|
401
|
-
</div>
|
|
402
|
-
</SignedOut>
|
|
403
|
-
|
|
404
|
-
<SignedIn>
|
|
405
|
-
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
|
406
|
-
<div>
|
|
407
|
-
<div className="font-medium">
|
|
408
|
-
{user?.primaryEmailAddress?.emailAddress ?? user?.username ?? 'Signed in'}
|
|
409
|
-
</div>
|
|
410
|
-
<p className="text-sm text-muted-foreground">
|
|
411
|
-
Plan: {hasPro ? 'Pro' : 'Free'}
|
|
412
|
-
</p>
|
|
413
|
-
<p className="text-sm text-muted-foreground">
|
|
414
|
-
Status: {statusLabel}
|
|
415
|
-
</p>
|
|
416
|
-
<p className="text-sm text-muted-foreground">
|
|
417
|
-
Last validated: {formatTimestamp(desktopEntitlement.validatedAt)}
|
|
418
|
-
</p>
|
|
419
|
-
</div>
|
|
420
|
-
<div className="flex items-center gap-3">
|
|
421
|
-
<UserButton afterSignOutUrl="/" />
|
|
422
|
-
<Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
|
|
423
|
-
{isSignedIn && hasPro ? 'Manage Plan' : 'Upgrade to Pro'}
|
|
424
|
-
</Button>
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
</SignedIn>
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
430
|
-
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import { useEffect, useRef } from 'react';
|
|
5
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
6
6
|
import { ThemeToggle } from 'beautiful-theme-toggle';
|
|
7
7
|
import { useViewerStore } from '@/store';
|
|
8
8
|
|
|
@@ -12,10 +12,22 @@ import { useViewerStore } from '@/store';
|
|
|
12
12
|
* Bidirectional sync:
|
|
13
13
|
* - User clicks the widget → onChange → store.setTheme
|
|
14
14
|
* - External change (keyboard shortcut / command palette) → store updates → widget.setTheme
|
|
15
|
+
*
|
|
16
|
+
* Secret colorful mode:
|
|
17
|
+
* - Hold Shift while clicking → toggles the hidden "colorful" theme
|
|
18
|
+
* - The sun/moon widget can't represent a third state, so it shows "sun" (day vibes)
|
|
19
|
+
* while the colorful gradient takes over the world.
|
|
15
20
|
*/
|
|
16
21
|
export function ThemeSwitch() {
|
|
17
22
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
18
23
|
const toggleRef = useRef<ThemeToggle | null>(null);
|
|
24
|
+
// Track whether Shift was held during the click so we can intercept in onChange
|
|
25
|
+
const shiftHeldRef = useRef(false);
|
|
26
|
+
|
|
27
|
+
// Capture shift key state on pointerdown (fires before the widget's internal click handler)
|
|
28
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
29
|
+
shiftHeldRef.current = e.shiftKey;
|
|
30
|
+
}, []);
|
|
19
31
|
|
|
20
32
|
useEffect(() => {
|
|
21
33
|
if (!containerRef.current) return;
|
|
@@ -25,9 +37,36 @@ export function ThemeSwitch() {
|
|
|
25
37
|
const toggle = new ThemeToggle({
|
|
26
38
|
element: containerRef.current,
|
|
27
39
|
size: 80,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
// Colorful → show sun (it's a bright/day-ish theme)
|
|
41
|
+
initialState: currentTheme === 'dark' ? 'dark' : 'light',
|
|
42
|
+
onChange: (widgetState) => {
|
|
43
|
+
const store = useViewerStore.getState();
|
|
44
|
+
|
|
45
|
+
if (shiftHeldRef.current) {
|
|
46
|
+
// Secret shift-click: toggle colorful mode
|
|
47
|
+
shiftHeldRef.current = false;
|
|
48
|
+
store.toggleColorful();
|
|
49
|
+
|
|
50
|
+
// Sync widget visual: colorful → sun, otherwise follow the new theme
|
|
51
|
+
const newTheme = useViewerStore.getState().theme;
|
|
52
|
+
const widgetTarget = newTheme === 'dark' ? 'dark' : 'light';
|
|
53
|
+
if (toggleRef.current && toggleRef.current.getTheme() !== widgetTarget) {
|
|
54
|
+
toggleRef.current.setTheme(widgetTarget, false);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Normal click: dark ↔ light (if colorful, drops to dark)
|
|
60
|
+
shiftHeldRef.current = false;
|
|
61
|
+
store.toggleTheme();
|
|
62
|
+
|
|
63
|
+
// The widget already animated to widgetState, but toggleTheme may have
|
|
64
|
+
// produced a different result (e.g. colorful → dark). Reconcile:
|
|
65
|
+
const newTheme = useViewerStore.getState().theme;
|
|
66
|
+
const expectedWidget = newTheme === 'dark' ? 'dark' : 'light';
|
|
67
|
+
if (toggleRef.current && widgetState !== expectedWidget) {
|
|
68
|
+
toggleRef.current.setTheme(expectedWidget, false);
|
|
69
|
+
}
|
|
31
70
|
},
|
|
32
71
|
});
|
|
33
72
|
|
|
@@ -38,8 +77,9 @@ export function ThemeSwitch() {
|
|
|
38
77
|
const unsub = useViewerStore.subscribe((state) => {
|
|
39
78
|
if (state.theme !== prevTheme) {
|
|
40
79
|
prevTheme = state.theme;
|
|
41
|
-
|
|
42
|
-
|
|
80
|
+
const widgetTarget = state.theme === 'dark' ? 'dark' : 'light';
|
|
81
|
+
if (toggleRef.current && toggleRef.current.getTheme() !== widgetTarget) {
|
|
82
|
+
toggleRef.current.setTheme(widgetTarget, false);
|
|
43
83
|
}
|
|
44
84
|
}
|
|
45
85
|
});
|
|
@@ -51,5 +91,21 @@ export function ThemeSwitch() {
|
|
|
51
91
|
};
|
|
52
92
|
}, []);
|
|
53
93
|
|
|
54
|
-
|
|
94
|
+
const theme = useViewerStore((s) => s.theme);
|
|
95
|
+
const isColorful = theme === 'colorful';
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
ref={containerRef}
|
|
100
|
+
onPointerDown={handlePointerDown}
|
|
101
|
+
className={`flex items-center cursor-pointer transition-all duration-300 ${
|
|
102
|
+
isColorful
|
|
103
|
+
? 'opacity-100 scale-110'
|
|
104
|
+
: 'opacity-80 hover:opacity-100'
|
|
105
|
+
}`}
|
|
106
|
+
style={isColorful ? {
|
|
107
|
+
filter: 'drop-shadow(0 0 6px rgba(157,124,216,0.5)) drop-shadow(0 0 12px rgba(255,158,100,0.25))',
|
|
108
|
+
} : undefined}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
55
111
|
}
|
|
@@ -186,6 +186,7 @@ export function ViewerLayout() {
|
|
|
186
186
|
// Keep DOM class in sync when theme changes (initial class is set by inline script in index.html)
|
|
187
187
|
useEffect(() => {
|
|
188
188
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
|
189
|
+
document.documentElement.classList.toggle('colorful', theme === 'colorful');
|
|
189
190
|
}, [theme]);
|
|
190
191
|
|
|
191
192
|
|
|
@@ -312,7 +312,7 @@ export function Viewport({
|
|
|
312
312
|
if (cesiumActive) {
|
|
313
313
|
clearColorRef.current = [0, 0, 0, 0]; // fully transparent
|
|
314
314
|
} else {
|
|
315
|
-
clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
|
|
315
|
+
clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark' | 'colorful');
|
|
316
316
|
}
|
|
317
317
|
rendererRef.current?.requestRender();
|
|
318
318
|
}, [cesiumActive, theme]);
|
|
@@ -878,13 +878,25 @@ export function Viewport({
|
|
|
878
878
|
// The model will be rendered by Cesium (as GLB) for correct positioning.
|
|
879
879
|
// Canvas stays in the DOM for picking/interaction.
|
|
880
880
|
|
|
881
|
+
// Colorful mode: transparent WebGPU clear colour + CSS gradient on the
|
|
882
|
+
// canvas element. The gradient is the *CSS background* of the <canvas>;
|
|
883
|
+
// premultiplied-alpha compositing shows it through transparent clear-colour
|
|
884
|
+
// regions while opaque model fragments (alpha=1) stay fully visible.
|
|
885
|
+
const canvasStyle = cesiumActive
|
|
886
|
+
? { opacity: 0 }
|
|
887
|
+
: theme === 'colorful'
|
|
888
|
+
? {
|
|
889
|
+
background: 'linear-gradient(180deg, #4a5a8a 0%, #6272a8 10%, #7e8dba 20%, #9aa3c8 32%, #b5b8d1 44%, #cdc3d4 56%, #dcccc8 68%, #e8d5be 80%, #f0ddb8 92%, #f5e2b6 100%)',
|
|
890
|
+
}
|
|
891
|
+
: undefined;
|
|
892
|
+
|
|
881
893
|
return (
|
|
882
894
|
<canvas
|
|
883
895
|
ref={canvasRef}
|
|
884
896
|
data-viewport="main"
|
|
885
897
|
tabIndex={-1}
|
|
886
898
|
className={`w-full h-full block ${cesiumActive ? 'relative z-[1]' : ''}`}
|
|
887
|
-
style={
|
|
899
|
+
style={canvasStyle}
|
|
888
900
|
onPointerDown={focusViewportForKeyboardShortcuts}
|
|
889
901
|
/>
|
|
890
902
|
);
|