@adea-ai/ui 0.10.8 → 0.10.9
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 +1 -0
- package/dist/components/account-drawer.d.ts +11 -0
- package/dist/components/account-drawer.js +20 -0
- package/dist/components/on-screen-controls.d.ts +7 -0
- package/dist/components/on-screen-controls.js +50 -0
- package/dist/components/prop-catalog.d.ts +49 -0
- package/dist/components/prop-catalog.js +342 -0
- package/dist/components/scene-settings.d.ts +33 -0
- package/dist/components/scene-settings.js +34 -0
- package/dist/components/theme-provider.d.ts +5 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/theme-toggle.d.ts +7 -0
- package/dist/components/theme-toggle.js +20 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +33 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +35 -0
- package/dist/components/ui/card.d.ts +4 -0
- package/dist/components/ui/card.js +9 -0
- package/dist/components/ui/dialog.d.ts +15 -0
- package/dist/components/ui/dialog.js +36 -0
- package/dist/components/ui/drawer.d.ts +16 -0
- package/dist/components/ui/drawer.js +75 -0
- package/dist/components/ui/dropdown-menu.d.ts +29 -0
- package/dist/components/ui/dropdown-menu.js +51 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +66 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +7 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/radio-group.d.ts +5 -0
- package/dist/components/ui/radio-group.js +12 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/spinner.d.ts +4 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/switch.d.ts +5 -0
- package/dist/components/ui/switch.js +8 -0
- package/dist/components/ui/tabs.d.ts +10 -0
- package/dist/components/ui/tabs.js +29 -0
- package/dist/components/ui/toggle-group.d.ts +11 -0
- package/dist/components/ui/toggle-group.js +24 -0
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +26 -0
- package/dist/components/ui/tooltip.d.ts +8 -0
- package/dist/components/ui/tooltip.js +18 -0
- package/dist/components/version-dialog.d.ts +25 -0
- package/dist/components/version-dialog.js +136 -0
- package/dist/components/workspace-brand.d.ts +5 -0
- package/dist/components/workspace-brand.js +4 -0
- package/dist/components/workspace-logo.d.ts +3 -0
- package/dist/components/workspace-logo.js +5 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lib/version-notes.d.ts +2 -0
- package/dist/lib/version-notes.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc -p tsconfig.json
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type AccountDrawerProps = {
|
|
3
|
+
accountLabel?: string;
|
|
4
|
+
authenticated?: boolean;
|
|
5
|
+
busy?: boolean;
|
|
6
|
+
musicControl?: ReactNode;
|
|
7
|
+
triggerTargetId?: string;
|
|
8
|
+
onSignIn?: () => void;
|
|
9
|
+
onSignOut?: () => void;
|
|
10
|
+
};
|
|
11
|
+
export declare function AccountDrawer({ accountLabel, authenticated, busy, musicControl, triggerTargetId, onSignIn, onSignOut, }: AccountDrawerProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { UserRound } from "lucide-react";
|
|
6
|
+
import { Button } from "#components/ui/button";
|
|
7
|
+
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, } from "#components/ui/drawer";
|
|
8
|
+
import { ThemeToggle } from "./theme-toggle";
|
|
9
|
+
export function AccountDrawer({ accountLabel = "Sign in", authenticated = false, busy = false, musicControl, triggerTargetId, onSignIn, onSignOut, }) {
|
|
10
|
+
const [triggerTarget, setTriggerTarget] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
setTriggerTarget(triggerTargetId ? document.getElementById(triggerTargetId) : null);
|
|
13
|
+
}, [triggerTargetId]);
|
|
14
|
+
const trigger = (_jsxs(DrawerTrigger, { render: _jsx(Button, { variant: "outline", size: "sm", className: "workspace-account-trigger", "aria-label": `Open user menu for ${accountLabel}`, "aria-haspopup": "dialog" }), children: [_jsx(UserRound, { "aria-hidden": "true" }), _jsx("span", { children: accountLabel })] }));
|
|
15
|
+
return (_jsxs(Drawer, { swipeDirection: "right", children: [triggerTarget ? (createPortal(trigger, triggerTarget)) : (_jsx("div", { className: "fixed right-4 top-4 z-40", children: trigger })), _jsxs(DrawerContent, { className: "w-[min(24rem,90vw)]", children: [_jsx(DrawerHeader, { className: "border-b px-5 pb-4 pt-5 text-left", children: _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("span", { className: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground", children: _jsx(UserRound, { className: "size-5", "aria-hidden": "true" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx(DrawerTitle, { className: "truncate", children: accountLabel }), _jsx(DrawerDescription, { children: authenticated
|
|
16
|
+
? "Signed in · workspace saved"
|
|
17
|
+
: "Guest workspace · sign in anytime" })] })] }), _jsx(ThemeToggle, { className: "shrink-0" })] }) }), _jsxs("div", { className: "flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto p-5", children: [musicControl ? (_jsxs("section", { className: "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-t pt-5", "aria-labelledby": "account-music-title", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: "account-music-title", className: "text-sm font-semibold", children: "Music" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Control the workspace soundtrack." })] }), _jsx("div", { className: "flex shrink-0 items-center justify-end", children: musicControl })] })) : null, _jsxs("section", { className: "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-t pt-5", "aria-labelledby": "account-session-title", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h2", { id: "account-session-title", className: "text-sm font-semibold", children: "Account" }), _jsx("p", { className: "text-sm text-muted-foreground", children: authenticated
|
|
18
|
+
? "Sign out on this device without removing your saved workspace."
|
|
19
|
+
: "Sign in or create an account to keep this workspace across devices." })] }), _jsx(DrawerClose, { render: _jsx(Button, { type: "button", className: "shrink-0", variant: authenticated ? "outline" : "default", disabled: busy, onClick: authenticated ? onSignOut : onSignIn }), children: authenticated ? "Sign out" : "Sign in" })] })] })] })] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type OnScreenControlsProps = {
|
|
2
|
+
onZoomIn?: () => void;
|
|
3
|
+
onZoomOut?: () => void;
|
|
4
|
+
showMovementControls?: boolean;
|
|
5
|
+
showJumpControl?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function OnScreenControls({ onZoomIn, onZoomOut, showMovementControls, showJumpControl, }?: OnScreenControlsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
4
|
+
import { ZoomIn, ZoomOut } from "lucide-react";
|
|
5
|
+
import { Button } from "#components/ui/button";
|
|
6
|
+
import { cn } from "#lib/utils";
|
|
7
|
+
function sendKeyEvent(type, code) {
|
|
8
|
+
window.dispatchEvent(new KeyboardEvent(type, { bubbles: true, cancelable: true, code, key: code }));
|
|
9
|
+
}
|
|
10
|
+
function ControlButton({ code, label, children, className }) {
|
|
11
|
+
const pressed = useRef(false);
|
|
12
|
+
const pressedPointer = useRef(null);
|
|
13
|
+
const release = useCallback(() => {
|
|
14
|
+
if (!pressed.current)
|
|
15
|
+
return;
|
|
16
|
+
pressed.current = false;
|
|
17
|
+
pressedPointer.current = null;
|
|
18
|
+
sendKeyEvent("keyup", code);
|
|
19
|
+
}, [code]);
|
|
20
|
+
// Self-heal: release the key if the pointerup lands elsewhere (e.g. the
|
|
21
|
+
// scene DOM changes mid-teleport) or the window loses focus. PointerId
|
|
22
|
+
// matching preserves multi-touch.
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const onWindowPointerUp = (event) => {
|
|
25
|
+
if (pressedPointer.current === event.pointerId)
|
|
26
|
+
release();
|
|
27
|
+
};
|
|
28
|
+
const onBlur = () => release();
|
|
29
|
+
window.addEventListener("pointerup", onWindowPointerUp);
|
|
30
|
+
window.addEventListener("blur", onBlur);
|
|
31
|
+
return () => {
|
|
32
|
+
window.removeEventListener("pointerup", onWindowPointerUp);
|
|
33
|
+
window.removeEventListener("blur", onBlur);
|
|
34
|
+
};
|
|
35
|
+
}, [release]);
|
|
36
|
+
const press = useCallback((event) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
if (pressed.current)
|
|
39
|
+
return;
|
|
40
|
+
pressed.current = true;
|
|
41
|
+
pressedPointer.current = event.pointerId;
|
|
42
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
43
|
+
sendKeyEvent("keydown", code);
|
|
44
|
+
}, [code]);
|
|
45
|
+
return (_jsx("button", { type: "button", "aria-label": label, className: cn("flex size-14 touch-none select-none items-center justify-center rounded-2xl border border-white/25 bg-slate-950/65 p-2 text-white shadow-lg backdrop-blur-sm transition active:scale-95 [-webkit-touch-callout:none] [-webkit-user-select:none]", className), onContextMenu: (event) => event.preventDefault(), onPointerCancel: release, onPointerDown: press, onPointerLeave: release, onPointerUp: release, children: children }));
|
|
46
|
+
}
|
|
47
|
+
export function OnScreenControls({ onZoomIn, onZoomOut, showMovementControls = true, showJumpControl = true, } = {}) {
|
|
48
|
+
const showZoomControls = Boolean(onZoomIn && onZoomOut);
|
|
49
|
+
return (_jsxs("div", { "data-agent-hq-on-screen-controls": true, className: "pointer-events-none fixed inset-x-4 bottom-4 z-30 flex select-none items-end justify-between gap-4 pb-[env(safe-area-inset-bottom)] sm:inset-x-6 sm:bottom-6 [-webkit-touch-callout:none] [-webkit-user-select:none]", children: [showMovementControls ? (_jsxs("div", { className: "pointer-events-auto grid grid-cols-3 gap-1.5 rounded-3xl bg-slate-950/20 p-2 backdrop-blur-[2px]", children: [_jsx("span", {}), _jsx(ControlButton, { code: "KeyW", label: "Move forward", className: "size-12 rounded-xl text-xl", children: "\u25B2" }), _jsx("span", {}), _jsx(ControlButton, { code: "KeyA", label: "Move left", className: "size-12 rounded-xl text-xl", children: "\u25C0" }), _jsx(ControlButton, { code: "KeyS", label: "Move backward", className: "size-12 rounded-xl text-xl", children: "\u25BC" }), _jsx(ControlButton, { code: "KeyD", label: "Move right", className: "size-12 rounded-xl text-xl", children: "\u25B6" })] })) : null, _jsxs("div", { className: "pointer-events-auto ml-auto flex flex-col items-end gap-2", children: [showZoomControls ? (_jsxs("div", { className: "flex items-center gap-1.5 rounded-2xl bg-slate-950/20 p-1.5 backdrop-blur-[2px]", role: "group", "aria-label": "Camera zoom", children: [_jsx(Button, { type: "button", variant: "secondary", size: "icon-lg", "aria-label": "Zoom out", title: "Zoom out", className: "border border-white/25 bg-slate-950/65 text-white shadow-lg backdrop-blur-sm hover:bg-slate-900/80", onClick: onZoomOut, children: _jsx(ZoomOut, { "aria-hidden": "true" }) }), _jsx(Button, { type: "button", variant: "secondary", size: "icon-lg", "aria-label": "Zoom in", title: "Zoom in", className: "border border-white/25 bg-slate-950/65 text-white shadow-lg backdrop-blur-sm hover:bg-slate-900/80", onClick: onZoomIn, children: _jsx(ZoomIn, { "aria-hidden": "true" }) })] })) : null, showJumpControl ? (_jsx(ControlButton, { code: "Space", label: "Jump", className: "text-xs font-semibold", children: "JUMP" })) : null] })] }));
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import { type LucideIcon } from "lucide-react";
|
|
3
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
4
|
+
export type PropCatalogItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
assetUrl: string;
|
|
8
|
+
category: string;
|
|
9
|
+
/** Restrict placement to a floor or a wall surface. */
|
|
10
|
+
placementSurface?: "floor" | "wall";
|
|
11
|
+
/** Authored wall-mount height for wall-only props. */
|
|
12
|
+
wallMountHeight?: number;
|
|
13
|
+
/** Render the placement footprint as a circle instead of an AABB. */
|
|
14
|
+
footprintShape?: "rectangle" | "circle";
|
|
15
|
+
/** Small authored lift used to keep floor props off coplanar surfaces. */
|
|
16
|
+
floorLift?: number;
|
|
17
|
+
/** Allow other floor props to be placed on this surface. */
|
|
18
|
+
allowItemsOnTop?: boolean;
|
|
19
|
+
/** Allow this floor prop to be placed underneath existing furniture. */
|
|
20
|
+
canOverlapFurniture?: boolean;
|
|
21
|
+
/** Keep rugs from covering this floor decoration. */
|
|
22
|
+
blocksRugOverlap?: boolean;
|
|
23
|
+
/** Top surface height in authored units (tables, counters). */
|
|
24
|
+
surfaceHeight?: number;
|
|
25
|
+
/** Small item that stacks on surfaces with surfaceHeight. */
|
|
26
|
+
placeableOnTop?: boolean;
|
|
27
|
+
/** Yaw that presents the asset's authored front to the viewer. */
|
|
28
|
+
frontYaw?: number;
|
|
29
|
+
};
|
|
30
|
+
export type PropCatalogCategory = {
|
|
31
|
+
id: string;
|
|
32
|
+
label: string;
|
|
33
|
+
icon: LucideIcon;
|
|
34
|
+
};
|
|
35
|
+
export declare const defaultPropCatalogCategories: readonly PropCatalogCategory[];
|
|
36
|
+
export declare function getSharedLoader(): GLTFLoader;
|
|
37
|
+
export declare const ModelThumbnail: import("react").MemoExoticComponent<({ item, deferMs, }: {
|
|
38
|
+
item: PropCatalogItem;
|
|
39
|
+
/** Delay expensive GLB preview work so nearby controls remain responsive. */
|
|
40
|
+
deferMs?: number;
|
|
41
|
+
}) => import("react").JSX.Element>;
|
|
42
|
+
export type PropCatalogProps = {
|
|
43
|
+
items: readonly PropCatalogItem[];
|
|
44
|
+
selectedId?: string | null;
|
|
45
|
+
categories?: readonly PropCatalogCategory[];
|
|
46
|
+
className?: string;
|
|
47
|
+
onItemPointerDown?: (item: PropCatalogItem, event: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
48
|
+
};
|
|
49
|
+
export declare function PropCatalog({ items, selectedId, categories, className, onItemPointerDown, }: PropCatalogProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { memo, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { Archive, Armchair, Bath, Baby, BedDouble, ChefHat, ChevronLeft, ChevronRight, Dumbbell, Gamepad2, Image, LampDesk, Leaf, PackageOpen, PanelTop, SquareDashed, Sprout, Store, Tv, Utensils, } from "lucide-react";
|
|
5
|
+
import * as THREE from "three";
|
|
6
|
+
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
7
|
+
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
|
8
|
+
import { Card } from "./ui/card";
|
|
9
|
+
import { cn } from "../lib/utils";
|
|
10
|
+
export const defaultPropCatalogCategories = [
|
|
11
|
+
{ id: "seating", label: "Seating", icon: Armchair },
|
|
12
|
+
{ id: "tables", label: "Tables", icon: PanelTop },
|
|
13
|
+
{ id: "bedroom", label: "Bedroom", icon: BedDouble },
|
|
14
|
+
{ id: "storage", label: "Storage", icon: Archive },
|
|
15
|
+
{ id: "lighting", label: "Lighting", icon: LampDesk },
|
|
16
|
+
{ id: "electronics", label: "Electronics", icon: Tv },
|
|
17
|
+
{ id: "entertainment", label: "Entertainment", icon: Gamepad2 },
|
|
18
|
+
{ id: "recreation", label: "Recreation", icon: Dumbbell },
|
|
19
|
+
{ id: "kitchen", label: "Kitchen", icon: ChefHat },
|
|
20
|
+
{ id: "bathroom", label: "Bathroom", icon: Bath },
|
|
21
|
+
{ id: "rugs", label: "Rugs", icon: SquareDashed },
|
|
22
|
+
{ id: "retail", label: "Retail", icon: Store },
|
|
23
|
+
{ id: "fitness", label: "Fitness", icon: Dumbbell },
|
|
24
|
+
{ id: "kids", label: "Kids", icon: Baby },
|
|
25
|
+
{ id: "wall-art", label: "Wall Art", icon: Image },
|
|
26
|
+
{ id: "plants", label: "Plants", icon: Sprout },
|
|
27
|
+
{ id: "food-and-drinks", label: "Food & Drinks", icon: Utensils },
|
|
28
|
+
{ id: "other", label: "Other", icon: PackageOpen },
|
|
29
|
+
];
|
|
30
|
+
function disposeObject(object) {
|
|
31
|
+
object.traverse((child) => {
|
|
32
|
+
if (!(child instanceof THREE.Mesh))
|
|
33
|
+
return;
|
|
34
|
+
child.geometry.dispose();
|
|
35
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
36
|
+
for (const material of materials)
|
|
37
|
+
material.dispose();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
// --- Thumbnail infrastructure ------------------------------------------------
|
|
41
|
+
// Shared singleton renderer + canvas for all thumbnails.
|
|
42
|
+
let thumbnailRenderer = null;
|
|
43
|
+
let thumbnailRendererCanvas = null;
|
|
44
|
+
// Shared singleton GLTFLoader so the browser can reuse connections and
|
|
45
|
+
// the loader can cache parsed resources internally. Exported so the
|
|
46
|
+
// room designer can reuse the same loader instance and avoid duplicate
|
|
47
|
+
// GLB fetches/parse work.
|
|
48
|
+
let sharedLoader = null;
|
|
49
|
+
export function getSharedLoader() {
|
|
50
|
+
if (!sharedLoader) {
|
|
51
|
+
sharedLoader = new GLTFLoader();
|
|
52
|
+
// Interior catalog GLBs are Meshopt-compressed. Configure the shared
|
|
53
|
+
// loader before thumbnails or the room designer can request any asset.
|
|
54
|
+
sharedLoader.setMeshoptDecoder(MeshoptDecoder);
|
|
55
|
+
}
|
|
56
|
+
return sharedLoader;
|
|
57
|
+
}
|
|
58
|
+
// Cache of rendered thumbnail data URLs keyed by asset URL + yaw.
|
|
59
|
+
// Survives category switches and drawer re-opens so thumbnails only
|
|
60
|
+
// render once per session.
|
|
61
|
+
const thumbnailDataCache = new Map();
|
|
62
|
+
// Track in-flight load+render promises so concurrent mounts share one.
|
|
63
|
+
const thumbnailPromiseCache = new Map();
|
|
64
|
+
// Count mounted consumers so category switches can abandon queued thumbnail
|
|
65
|
+
// renders instead of keeping the main thread busy with an invisible catalog.
|
|
66
|
+
const thumbnailConsumerCounts = new Map();
|
|
67
|
+
function thumbnailCacheKey(item) {
|
|
68
|
+
return `${item.assetUrl}|${item.frontYaw ?? 0}`;
|
|
69
|
+
}
|
|
70
|
+
function renderThumbnailToDataURL(job) {
|
|
71
|
+
if (!thumbnailRendererCanvas)
|
|
72
|
+
thumbnailRendererCanvas = document.createElement("canvas");
|
|
73
|
+
if (!thumbnailRenderer) {
|
|
74
|
+
thumbnailRenderer = new THREE.WebGLRenderer({
|
|
75
|
+
canvas: thumbnailRendererCanvas,
|
|
76
|
+
alpha: true,
|
|
77
|
+
antialias: true,
|
|
78
|
+
powerPreference: "low-power",
|
|
79
|
+
});
|
|
80
|
+
thumbnailRenderer.setPixelRatio(1);
|
|
81
|
+
thumbnailRenderer.setSize(96, 96, false);
|
|
82
|
+
thumbnailRenderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
83
|
+
}
|
|
84
|
+
const scene = new THREE.Scene();
|
|
85
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x223044, 2.4));
|
|
86
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.8);
|
|
87
|
+
key.position.set(2, 4, 3);
|
|
88
|
+
scene.add(key);
|
|
89
|
+
const camera = new THREE.PerspectiveCamera(28, 1, 0.01, 100);
|
|
90
|
+
const model = job.source;
|
|
91
|
+
model.rotation.y = job.item.frontYaw ?? 0;
|
|
92
|
+
model.updateMatrixWorld(true);
|
|
93
|
+
const bounds = new THREE.Box3().setFromObject(model);
|
|
94
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
95
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
96
|
+
model.position.sub(center);
|
|
97
|
+
const extent = Math.max(size.x, size.y, size.z, 0.25);
|
|
98
|
+
camera.position.set(extent * 2.25, extent * 1.55, -extent * 2.25);
|
|
99
|
+
camera.lookAt(0, Math.max(size.y * 0.12, 0), 0);
|
|
100
|
+
scene.add(model);
|
|
101
|
+
thumbnailRenderer.render(scene, camera);
|
|
102
|
+
// JPEG is ~5x faster to encode than PNG and produces smaller data URLs,
|
|
103
|
+
// which speeds up both the toDataURL call and the subsequent img.src load.
|
|
104
|
+
const dataUrl = thumbnailRendererCanvas.toDataURL("image/jpeg", 0.85);
|
|
105
|
+
scene.remove(model);
|
|
106
|
+
disposeObject(model);
|
|
107
|
+
return dataUrl;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Yield to the event loop so the browser can process user input between
|
|
111
|
+
* thumbnail renders. Without this, a queue of N renders executes as one
|
|
112
|
+
* synchronous batch (microtask drain) and blocks the main thread.
|
|
113
|
+
*/
|
|
114
|
+
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
115
|
+
/**
|
|
116
|
+
* Sequential render queue. Each render waits for one animation frame before
|
|
117
|
+
* executing, keeping the UI responsive even when many thumbnails are queued.
|
|
118
|
+
*/
|
|
119
|
+
let renderQueue = Promise.resolve();
|
|
120
|
+
function scheduleRender(render) {
|
|
121
|
+
const result = renderQueue.then(async () => {
|
|
122
|
+
await nextFrame();
|
|
123
|
+
return render();
|
|
124
|
+
});
|
|
125
|
+
renderQueue = result.catch(() => undefined);
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Load a GLB and render a thumbnail, returning a data URL.
|
|
130
|
+
* Results are cached so repeated mounts (category switches, drawer re-opens)
|
|
131
|
+
* skip the fetch + render entirely. Concurrent mounts for the same asset
|
|
132
|
+
* share a single in-flight promise.
|
|
133
|
+
*
|
|
134
|
+
* GLB loads are concurrency-limited to avoid firing 50+ simultaneous HTTP
|
|
135
|
+
* requests when a category with many items becomes visible. The render
|
|
136
|
+
* queue (scheduleRender) already serializes the sync WebGL renders, but
|
|
137
|
+
* the async GLB fetches should still be bounded. Four concurrent loads fill
|
|
138
|
+
* the visible catalog quickly without starting dozens of requests at once.
|
|
139
|
+
*/
|
|
140
|
+
const MAX_CONCURRENT_THUMBNAIL_LOADS = 4;
|
|
141
|
+
let activeThumbnailLoads = 0;
|
|
142
|
+
const pendingThumbnailLoads = [];
|
|
143
|
+
function dequeueThumbnailLoad() {
|
|
144
|
+
if (activeThumbnailLoads >= MAX_CONCURRENT_THUMBNAIL_LOADS)
|
|
145
|
+
return;
|
|
146
|
+
const next = pendingThumbnailLoads.shift();
|
|
147
|
+
if (!next)
|
|
148
|
+
return;
|
|
149
|
+
activeThumbnailLoads++;
|
|
150
|
+
next();
|
|
151
|
+
}
|
|
152
|
+
function acquireThumbnail(item) {
|
|
153
|
+
const key = thumbnailCacheKey(item);
|
|
154
|
+
const cached = thumbnailDataCache.get(key);
|
|
155
|
+
if (cached)
|
|
156
|
+
return { promise: Promise.resolve(cached), release: () => undefined };
|
|
157
|
+
thumbnailConsumerCounts.set(key, (thumbnailConsumerCounts.get(key) ?? 0) + 1);
|
|
158
|
+
let promise = thumbnailPromiseCache.get(key);
|
|
159
|
+
if (!promise) {
|
|
160
|
+
promise = new Promise((resolve, reject) => {
|
|
161
|
+
const run = () => {
|
|
162
|
+
if (!thumbnailConsumerCounts.has(key)) {
|
|
163
|
+
activeThumbnailLoads--;
|
|
164
|
+
dequeueThumbnailLoad();
|
|
165
|
+
thumbnailPromiseCache.delete(key);
|
|
166
|
+
resolve(null);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const finishLoad = () => {
|
|
170
|
+
activeThumbnailLoads--;
|
|
171
|
+
dequeueThumbnailLoad();
|
|
172
|
+
};
|
|
173
|
+
getSharedLoader()
|
|
174
|
+
.loadAsync(item.assetUrl)
|
|
175
|
+
.then(({ scene: source }) => {
|
|
176
|
+
finishLoad();
|
|
177
|
+
// The GLB parse is async, but the WebGL render + toDataURL is sync.
|
|
178
|
+
// Schedule the sync part in an animation frame so it doesn't block.
|
|
179
|
+
return scheduleRender(() => {
|
|
180
|
+
if (!thumbnailConsumerCounts.has(key)) {
|
|
181
|
+
thumbnailPromiseCache.delete(key);
|
|
182
|
+
disposeObject(source);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const dataUrl = renderThumbnailToDataURL({ item, source });
|
|
186
|
+
thumbnailDataCache.set(key, dataUrl);
|
|
187
|
+
thumbnailPromiseCache.delete(key);
|
|
188
|
+
return dataUrl;
|
|
189
|
+
});
|
|
190
|
+
}, (error) => {
|
|
191
|
+
finishLoad();
|
|
192
|
+
throw error;
|
|
193
|
+
})
|
|
194
|
+
.then(resolve, reject);
|
|
195
|
+
};
|
|
196
|
+
pendingThumbnailLoads.push(run);
|
|
197
|
+
dequeueThumbnailLoad();
|
|
198
|
+
});
|
|
199
|
+
thumbnailPromiseCache.set(key, promise);
|
|
200
|
+
}
|
|
201
|
+
let released = false;
|
|
202
|
+
return {
|
|
203
|
+
promise,
|
|
204
|
+
release: () => {
|
|
205
|
+
if (released)
|
|
206
|
+
return;
|
|
207
|
+
released = true;
|
|
208
|
+
const consumers = thumbnailConsumerCounts.get(key) ?? 0;
|
|
209
|
+
if (consumers <= 1)
|
|
210
|
+
thumbnailConsumerCounts.delete(key);
|
|
211
|
+
else
|
|
212
|
+
thumbnailConsumerCounts.set(key, consumers - 1);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export const ModelThumbnail = memo(function ModelThumbnail({ item, deferMs = 0, }) {
|
|
217
|
+
const wrapperRef = useRef(null);
|
|
218
|
+
const canvasRef = useRef(null);
|
|
219
|
+
const [visible, setVisible] = useState(false);
|
|
220
|
+
const [failed, setFailed] = useState(false);
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const wrapper = wrapperRef.current;
|
|
223
|
+
if (!wrapper || typeof IntersectionObserver === "undefined") {
|
|
224
|
+
setVisible(true);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Use a rootMargin so items slightly below the fold start loading
|
|
228
|
+
// before the user scrolls to them, making scroll feel instant.
|
|
229
|
+
const scrollRoot = wrapper.closest("[data-model-thumbnail-root]");
|
|
230
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
231
|
+
setVisible(Boolean(entry?.isIntersecting));
|
|
232
|
+
}, { root: scrollRoot, rootMargin: "200px" });
|
|
233
|
+
observer.observe(wrapper);
|
|
234
|
+
return () => observer.disconnect();
|
|
235
|
+
}, []);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!visible)
|
|
238
|
+
return;
|
|
239
|
+
const canvas = canvasRef.current;
|
|
240
|
+
if (!canvas)
|
|
241
|
+
return;
|
|
242
|
+
let cancelled = false;
|
|
243
|
+
let thumbnail;
|
|
244
|
+
const load = () => {
|
|
245
|
+
if (cancelled)
|
|
246
|
+
return;
|
|
247
|
+
thumbnail = acquireThumbnail(item);
|
|
248
|
+
void thumbnail.promise
|
|
249
|
+
.then((dataUrl) => {
|
|
250
|
+
if (cancelled || !dataUrl)
|
|
251
|
+
return;
|
|
252
|
+
const ctx = canvas.getContext("2d");
|
|
253
|
+
if (!ctx)
|
|
254
|
+
return;
|
|
255
|
+
const img = document.createElement("img");
|
|
256
|
+
img.onload = () => {
|
|
257
|
+
if (cancelled)
|
|
258
|
+
return;
|
|
259
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
260
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
261
|
+
};
|
|
262
|
+
img.src = dataUrl;
|
|
263
|
+
})
|
|
264
|
+
.catch(() => {
|
|
265
|
+
if (!cancelled)
|
|
266
|
+
setFailed(true);
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
const timer = window.setTimeout(load, deferMs);
|
|
270
|
+
return () => {
|
|
271
|
+
cancelled = true;
|
|
272
|
+
window.clearTimeout(timer);
|
|
273
|
+
thumbnail?.release();
|
|
274
|
+
};
|
|
275
|
+
}, [deferMs, item, visible]);
|
|
276
|
+
return (_jsxs("div", { ref: wrapperRef, className: "relative flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-muted/60", children: [_jsx("canvas", { ref: canvasRef, width: 96, height: 96, className: cn("h-full w-full transition-opacity", failed ? "opacity-0" : "opacity-100"), "aria-hidden": "true" }), failed ? _jsx(Leaf, { className: "size-7 text-muted-foreground", "aria-hidden": "true" }) : null, _jsx("span", { className: "pointer-events-none absolute inset-x-1 bottom-1 truncate rounded bg-background/55 px-1.5 py-1 text-center text-[10px] font-medium text-foreground opacity-0 transition-opacity group-hover:opacity-100", children: item.label })] }));
|
|
277
|
+
});
|
|
278
|
+
export function PropCatalog({ items, selectedId = null, categories = defaultPropCatalogCategories, className, onItemPointerDown, }) {
|
|
279
|
+
const [activeCategory, setActiveCategory] = useState(categories[0]?.id ?? "seating");
|
|
280
|
+
const [hoveredCategory, setHoveredCategory] = useState(null);
|
|
281
|
+
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
|
282
|
+
const [canScrollRight, setCanScrollRight] = useState(false);
|
|
283
|
+
const categoryScrollRef = useRef(null);
|
|
284
|
+
const categoryBarRef = useRef(null);
|
|
285
|
+
const visibleItems = useMemo(() => {
|
|
286
|
+
return items.filter((item) => item.category === activeCategory);
|
|
287
|
+
}, [activeCategory, items]);
|
|
288
|
+
const updateCategoryScroll = () => {
|
|
289
|
+
const element = categoryScrollRef.current;
|
|
290
|
+
if (!element)
|
|
291
|
+
return;
|
|
292
|
+
setCanScrollLeft(element.scrollLeft > 1);
|
|
293
|
+
setCanScrollRight(element.scrollLeft + element.clientWidth < element.scrollWidth - 1);
|
|
294
|
+
setHoveredCategory(null);
|
|
295
|
+
};
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
updateCategoryScroll();
|
|
298
|
+
const element = categoryScrollRef.current;
|
|
299
|
+
if (!element)
|
|
300
|
+
return;
|
|
301
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateCategoryScroll);
|
|
302
|
+
observer?.observe(element);
|
|
303
|
+
element.addEventListener("scroll", updateCategoryScroll, { passive: true });
|
|
304
|
+
return () => {
|
|
305
|
+
observer?.disconnect();
|
|
306
|
+
element.removeEventListener("scroll", updateCategoryScroll);
|
|
307
|
+
};
|
|
308
|
+
}, [categories.length]);
|
|
309
|
+
const scrollCategories = (direction) => {
|
|
310
|
+
const element = categoryScrollRef.current;
|
|
311
|
+
if (!element)
|
|
312
|
+
return;
|
|
313
|
+
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
|
314
|
+
element.scrollTo({ left: direction === "left" ? 0 : maxScrollLeft, behavior: "auto" });
|
|
315
|
+
// The left arrow becomes visible after the first rightward scroll, which
|
|
316
|
+
// can reduce the viewport by one button width. Re-read the final extent
|
|
317
|
+
// after that layout update so the last category is never stranded.
|
|
318
|
+
if (direction === "right") {
|
|
319
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
320
|
+
const finalMaxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
|
321
|
+
element.scrollTo({ left: finalMaxScrollLeft, behavior: "auto" });
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
const setCategoryHover = (id, target) => {
|
|
326
|
+
const bar = categoryBarRef.current;
|
|
327
|
+
if (!bar)
|
|
328
|
+
return;
|
|
329
|
+
const targetRect = target.getBoundingClientRect();
|
|
330
|
+
const barRect = bar.getBoundingClientRect();
|
|
331
|
+
setHoveredCategory({ id, left: targetRect.left + targetRect.width / 2 - barRect.left });
|
|
332
|
+
};
|
|
333
|
+
return (_jsxs("div", { className: cn("flex min-h-0 flex-col gap-3", className), "aria-label": "Prop catalog", children: [_jsxs("div", { ref: categoryBarRef, className: "relative shrink-0 pb-1", role: "tablist", "aria-label": "Prop categories", children: [canScrollLeft ? (_jsx("span", { "aria-hidden": "true", className: "absolute left-0 top-0 z-10 size-8 rounded-md bg-background" })) : null, _jsx("button", { type: "button", tabIndex: canScrollLeft ? 0 : -1, "aria-hidden": !canScrollLeft, className: cn("absolute left-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground", !canScrollLeft ? "pointer-events-none invisible" : null), "aria-label": "Scroll categories left", onClick: () => scrollCategories("left"), children: _jsx(ChevronLeft, { className: "size-5", "aria-hidden": "true" }) }), _jsx("div", { ref: categoryScrollRef, className: "w-full overflow-x-auto overflow-y-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", children: _jsx("div", { className: "flex min-w-max gap-1", children: categories.map(({ id, label, icon: Icon }) => (_jsx("button", { type: "button", role: "tab", "aria-label": label, "aria-selected": activeCategory === id, className: cn("group/category relative flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background/30 text-muted-foreground transition-colors hover:border-primary/70 hover:bg-accent/70 hover:text-accent-foreground", activeCategory === id ? "border-primary bg-accent text-accent-foreground" : null), onClick: () => setActiveCategory(id), onMouseEnter: (event) => setCategoryHover(id, event.currentTarget), onMouseLeave: () => setHoveredCategory(null), onFocus: (event) => setCategoryHover(id, event.currentTarget), onBlur: () => setHoveredCategory(null), children: _jsx(Icon, { className: "size-4", "aria-hidden": "true" }) }, id))) }) }), canScrollRight ? (_jsx("span", { "aria-hidden": "true", className: "absolute right-0 top-0 z-10 size-8 rounded-md bg-background" })) : null, _jsx("button", { type: "button", tabIndex: canScrollRight ? 0 : -1, "aria-hidden": !canScrollRight, className: cn("absolute right-0 top-0 z-20 flex size-8 items-center justify-center rounded-md bg-background text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground", !canScrollRight ? "pointer-events-none invisible" : null), "aria-label": "Scroll categories right", onClick: () => scrollCategories("right"), children: _jsx(ChevronRight, { className: "size-5", "aria-hidden": "true" }) }), hoveredCategory ? (_jsx("div", { className: "pointer-events-none absolute top-full z-20 mt-1 rounded bg-background/90 px-2 py-1 text-[10px] font-medium text-foreground shadow-md backdrop-blur-sm", style: { left: hoveredCategory.left, transform: "translateX(-50%)" }, children: categories.find((category) => category.id === hoveredCategory.id)?.label })) : null] }), visibleItems.length > 0 ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto pr-1", role: "tabpanel", "data-model-thumbnail-root": true, children: _jsx("div", { className: "grid grid-cols-3 gap-2", children: visibleItems.map((item) => (_jsx(Card, { className: cn("group overflow-hidden border-border bg-card/60 p-1 transition-colors", selectedId === item.id
|
|
334
|
+
? "border-primary bg-accent/70"
|
|
335
|
+
: "hover:border-primary/50 hover:bg-accent/40"), children: _jsxs("button", { type: "button", "aria-label": `Add ${item.label}`, className: "block w-full rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring", onPointerDown: (event) => {
|
|
336
|
+
if (event.button !== 0)
|
|
337
|
+
return;
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
event.stopPropagation();
|
|
340
|
+
onItemPointerDown?.(item, event);
|
|
341
|
+
}, children: [_jsx(ModelThumbnail, { item: item }), _jsx("span", { className: "sr-only", children: item.label })] }) }, item.id))) }) })) : (_jsx("p", { className: "rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-muted-foreground", children: "No props in this category yet." }))] }));
|
|
342
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type SceneSettingsProps = {
|
|
3
|
+
cameraViewMode: "perspective" | "orthographic";
|
|
4
|
+
onCameraViewModeChange: (value: "perspective" | "orthographic") => void;
|
|
5
|
+
allowCameraViewModeChange?: boolean;
|
|
6
|
+
/** Standalone character selection and customization menu toggle. */
|
|
7
|
+
characterDesignerEnabled?: boolean;
|
|
8
|
+
onCharacterDesignerChange?: (value: boolean) => void;
|
|
9
|
+
/** DOM target for the character designer control in an app shell toolbar. */
|
|
10
|
+
characterDesignerTargetId?: string;
|
|
11
|
+
/** Open the scene-specific room layout and interior prop designer. */
|
|
12
|
+
onOpenRoomDesigner?: () => void;
|
|
13
|
+
/** DOM target for the account drawer trigger in an app shell toolbar. */
|
|
14
|
+
accountTargetId?: string;
|
|
15
|
+
accountLabel?: string;
|
|
16
|
+
accountAuthenticated?: boolean;
|
|
17
|
+
accountBusy?: boolean;
|
|
18
|
+
accountMusicControl?: ReactNode;
|
|
19
|
+
onAccountSignIn?: () => void;
|
|
20
|
+
onAccountSignOut?: () => void;
|
|
21
|
+
showAccountDrawer?: boolean;
|
|
22
|
+
/** DOM target for the compact camera controls in an app shell toolbar. */
|
|
23
|
+
cameraTargetId?: string;
|
|
24
|
+
/** DOM target for the room designer control in an app shell toolbar. */
|
|
25
|
+
roomDesignerTargetId?: string;
|
|
26
|
+
/** DOM target for the development scene editor control in an app shell toolbar. */
|
|
27
|
+
sceneEditorTargetId?: string;
|
|
28
|
+
/** Development-only scene editor toggle, available in perspective view. */
|
|
29
|
+
sceneEditorEnabled?: boolean;
|
|
30
|
+
onSceneEditorChange?: (value: boolean) => void;
|
|
31
|
+
};
|
|
32
|
+
/** Compact scene controls shared by the HQ shell and other scene hosts. */
|
|
33
|
+
export declare function SceneSettings({ cameraViewMode, onCameraViewModeChange, allowCameraViewModeChange, characterDesignerEnabled, onCharacterDesignerChange, characterDesignerTargetId, onOpenRoomDesigner, accountTargetId, accountLabel, accountAuthenticated, accountBusy, accountMusicControl, onAccountSignIn, onAccountSignOut, showAccountDrawer, cameraTargetId, roomDesignerTargetId, sceneEditorTargetId, sceneEditorEnabled, onSceneEditorChange, }: SceneSettingsProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { Box, Camera, Focus, Grid3X3, UserRoundPen } from "lucide-react";
|
|
6
|
+
import { AccountDrawer } from "./account-drawer";
|
|
7
|
+
import { Button } from "#components/ui/button";
|
|
8
|
+
function usePortalTarget(targetId) {
|
|
9
|
+
const [target, setTarget] = useState(null);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
setTarget(targetId ? document.getElementById(targetId) : null);
|
|
12
|
+
}, [targetId]);
|
|
13
|
+
return target;
|
|
14
|
+
}
|
|
15
|
+
function renderInTarget(content, target) {
|
|
16
|
+
return target ? createPortal(content, target) : content;
|
|
17
|
+
}
|
|
18
|
+
/** Compact scene controls shared by the HQ shell and other scene hosts. */
|
|
19
|
+
export function SceneSettings({ cameraViewMode, onCameraViewModeChange, allowCameraViewModeChange = true, characterDesignerEnabled, onCharacterDesignerChange, characterDesignerTargetId, onOpenRoomDesigner, accountTargetId, accountLabel, accountAuthenticated, accountBusy, accountMusicControl, onAccountSignIn, onAccountSignOut, showAccountDrawer = true, cameraTargetId, roomDesignerTargetId, sceneEditorTargetId, sceneEditorEnabled, onSceneEditorChange, }) {
|
|
20
|
+
const cameraTarget = usePortalTarget(cameraTargetId);
|
|
21
|
+
const characterDesignerTarget = usePortalTarget(characterDesignerTargetId);
|
|
22
|
+
const roomDesignerTarget = usePortalTarget(roomDesignerTargetId);
|
|
23
|
+
const sceneEditorTarget = usePortalTarget(sceneEditorTargetId);
|
|
24
|
+
const cameraControl = (_jsxs("div", { className: "workspace-camera-control", role: "group", "aria-label": "Camera view", children: [_jsxs(Button, { type: "button", size: "sm", variant: cameraViewMode === "perspective" ? "default" : "outline", className: "workspace-camera-button", "aria-label": "Perspective camera", title: "Perspective camera", "aria-pressed": cameraViewMode === "perspective", onClick: () => onCameraViewModeChange("perspective"), children: [_jsx(Camera, { "aria-hidden": "true" }), "Perspective"] }), _jsxs(Button, { type: "button", size: "sm", variant: cameraViewMode === "orthographic" ? "default" : "outline", className: "workspace-camera-button", "aria-label": "Top-down camera", title: "Top-down camera", "aria-pressed": cameraViewMode === "orthographic", onClick: () => onCameraViewModeChange("orthographic"), children: [_jsx(Focus, { "aria-hidden": "true" }), "Top-down"] })] }));
|
|
25
|
+
const characterDesignerControl = (_jsxs(Button, { type: "button", variant: characterDesignerEnabled ? "default" : "outline", size: "sm", "aria-haspopup": "dialog", "aria-label": characterDesignerEnabled ? "Close character designer" : "Open character designer", title: characterDesignerEnabled ? "Close character designer" : "Open character designer", "aria-pressed": characterDesignerEnabled, onClick: () => onCharacterDesignerChange?.(!characterDesignerEnabled), children: [_jsx(UserRoundPen, { className: "size-4", "aria-hidden": "true" }), _jsx("span", { className: "workspace-character-designer-label", children: "Character" })] }));
|
|
26
|
+
const roomDesignerControl = (_jsxs(Button, { type: "button", variant: "outline", size: "sm", "aria-haspopup": "dialog", "aria-label": "Open room designer", title: "Open room designer", onClick: onOpenRoomDesigner, children: [_jsx(Grid3X3, { className: "size-4", "aria-hidden": "true" }), _jsx("span", { className: "workspace-room-designer-label", children: "Room designer" })] }));
|
|
27
|
+
const sceneEditorControl = (_jsxs(Button, { type: "button", variant: sceneEditorEnabled ? "default" : "outline", size: "sm", "aria-haspopup": "dialog", "aria-label": sceneEditorEnabled ? "Close scene editor" : "Open scene editor", title: sceneEditorEnabled ? "Close scene editor" : "Open scene editor", "aria-pressed": sceneEditorEnabled, onClick: () => onSceneEditorChange?.(!sceneEditorEnabled), children: [_jsx(Box, { className: "size-4", "aria-hidden": "true" }), _jsx("span", { className: "workspace-scene-editor-label", children: "Scene editor" })] }));
|
|
28
|
+
return (_jsxs(_Fragment, { children: [showAccountDrawer ? (_jsx(AccountDrawer, { accountLabel: accountLabel, authenticated: accountAuthenticated, busy: accountBusy, musicControl: accountMusicControl, triggerTargetId: accountTargetId, onSignIn: onAccountSignIn, onSignOut: onAccountSignOut })) : null, allowCameraViewModeChange ? renderInTarget(cameraControl, cameraTarget) : null, characterDesignerEnabled != null &&
|
|
29
|
+
!characterDesignerEnabled &&
|
|
30
|
+
onCharacterDesignerChange ? (characterDesignerTarget ? (createPortal(characterDesignerControl, characterDesignerTarget)) : (_jsx("div", { className: "fixed right-4 top-16 z-40", children: characterDesignerControl }))) : null, onOpenRoomDesigner && cameraViewMode === "orthographic" ? (roomDesignerTarget ? (createPortal(roomDesignerControl, roomDesignerTarget)) : (_jsx("div", { className: "fixed right-4 top-16 z-40", children: roomDesignerControl }))) : null, sceneEditorEnabled != null &&
|
|
31
|
+
!sceneEditorEnabled &&
|
|
32
|
+
onSceneEditorChange &&
|
|
33
|
+
cameraViewMode === "perspective" ? (sceneEditorTarget ? (createPortal(sceneEditorControl, sceneEditorTarget)) : (_jsx("div", { className: "fixed right-4 top-16 z-40", children: sceneEditorControl }))) : null] }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
4
|
+
const CompatibleNextThemesProvider = NextThemesProvider;
|
|
5
|
+
/** Shared theme provider used by each Next app in the workspace. */
|
|
6
|
+
export function ThemeProvider({ children }) {
|
|
7
|
+
return (_jsx(CompatibleNextThemesProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, disableTransitionOnChange: true, children: children }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact sun/moon theme selector. Wired to the next-themes provider so the
|
|
3
|
+
* choice persists across the app (localStorage) and applies to every scene.
|
|
4
|
+
*/
|
|
5
|
+
export declare function ThemeToggle({ className }: {
|
|
6
|
+
className?: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import { Monitor, Moon, Sun } from "lucide-react";
|
|
5
|
+
import { useTheme } from "next-themes";
|
|
6
|
+
import { Button } from "#components/ui/button";
|
|
7
|
+
import { cn } from "#lib/utils";
|
|
8
|
+
/**
|
|
9
|
+
* Compact sun/moon theme selector. Wired to the next-themes provider so the
|
|
10
|
+
* choice persists across the app (localStorage) and applies to every scene.
|
|
11
|
+
*/
|
|
12
|
+
export function ThemeToggle({ className }) {
|
|
13
|
+
const { theme, setTheme } = useTheme();
|
|
14
|
+
const mounted = useSyncExternalStore(() => () => undefined, () => true, () => false);
|
|
15
|
+
// next-themes resolves the saved/system theme after mount. Use the stable
|
|
16
|
+
// light presentation for both SSR and the first client render so the
|
|
17
|
+
// aria/class attributes cannot mismatch during hydration.
|
|
18
|
+
const selectedTheme = mounted ? theme : "system";
|
|
19
|
+
return (_jsxs("div", { className: cn("inline-flex items-center gap-1.5 rounded-full border border-input bg-background p-1.5", className), role: "radiogroup", "aria-label": "Theme", children: [_jsx(Button, { type: "button", role: "radio", "aria-checked": selectedTheme === "system", "aria-label": "System theme", variant: selectedTheme === "system" ? "default" : "ghost", size: "icon-sm", onClick: () => setTheme("system"), className: "rounded-full", children: _jsx(Monitor, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { type: "button", role: "radio", "aria-checked": selectedTheme === "light", "aria-label": "Light theme", variant: selectedTheme === "light" ? "default" : "ghost", size: "icon-sm", onClick: () => setTheme("light"), className: "rounded-full", children: _jsx(Sun, { className: "size-4", "aria-hidden": "true" }) }), _jsx(Button, { type: "button", role: "radio", "aria-checked": selectedTheme === "dark", "aria-label": "Dark theme", variant: selectedTheme === "dark" ? "default" : "ghost", size: "icon-sm", onClick: () => setTheme("dark"), className: "rounded-full", children: _jsx(Moon, { className: "size-4", "aria-hidden": "true" }) })] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
2
|
+
import { type VariantProps } from "class-variance-authority";
|
|
3
|
+
declare const badgeVariants: (props?: ({
|
|
4
|
+
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive" | "link" | null | undefined;
|
|
5
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
|
+
declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
7
|
+
export { Badge, badgeVariants };
|