[READ-ONLY] Mirror of https://github.com/flo-bit/room. tiny 3d rooms saved locally or in your bluesky account, svelte/threlte flo-bit.dev/room/
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor pt1

Florian (Mar 8, 2025, 5:47 PM +0100) 5d3f1a75 1545a32d

+842 -801
+7 -3
README.md
··· 18 18 19 19 ### todo 20 20 21 - - show error message when room is not found 22 21 - allow placing objects on walls 23 22 - add more objects 24 23 - add animations ··· 30 29 - add modal for first visit (without handle) -> create your own room, see my room 31 30 - add tutorial 32 31 - resize objects in picker to perfectly fit 33 - - move back button in picker above objects (z-index) 34 32 - add sign out button 35 - - move preview button to main screen 33 + - move preview button to main screen 34 + - add undo button 35 + 36 + ### bugs 37 + 38 + - show error message when room is not found 39 + - fix moving with transform controls might select other object
+8 -22
src/lib/Room.svelte
··· 1 1 <script lang="ts"> 2 2 import * as THREE from 'three'; 3 3 import { T } from '@threlte/core'; 4 - import { applyTransformOfSelected, editorState, roomState, rotateObject } from './state.svelte'; 4 + import { applyTransformOfSelected, editorState, placeObject, roomState } from './state.svelte'; 5 5 import RoomObject from './RoomObject.svelte'; 6 6 import { Grid, RoundedBoxGeometry, TransformControls } from '@threlte/extras'; 7 - import { onMount } from 'svelte'; 8 7 9 - onMount(() => { 10 - window.addEventListener('keydown', (e) => { 11 - if (e.key === 'd' || e.key === 'ArrowRight') { 12 - rotateObject(-rotation); 13 - } else if (e.key === 'a' || e.key === 'ArrowLeft') { 14 - rotateObject(rotation); 15 - } 16 - }); 17 - }); 18 - 19 - let rotation = Math.PI / 8; 20 8 let snap = 0.125 * 0.125; 21 9 </script> 22 10 ··· 52 40 editorState.placingObject.position = [point.x, 0, point.z]; 53 41 } 54 42 }} 55 - onpointerdown={(e: { point: { x: number; z: number } }) => { 43 + onclick={(e: { stopPropagation: () => void; point: { x: number; y: number; z: number } }) => { 44 + e.stopPropagation(); 45 + 56 46 let point = e.point; 57 47 58 48 // round to snap point 59 49 point.x = Math.round(point.x / snap) * snap; 60 50 point.z = Math.round(point.z / snap) * snap; 61 51 62 - if (editorState.placingObject) { 63 - roomState.objects.push(editorState.placingObject); 64 - editorState.placingObject.position = [point.x, 0, point.z]; 65 - editorState.selectedObject = editorState.placingObject; 66 - 67 - editorState.placingObject = null; 68 - } 52 + placeObject(point); 69 53 }} 70 54 ondblclick={() => { 71 55 if (editorState.selectedObject) { ··· 84 68 <T.MeshStandardMaterial color={roomState.floorColor} /> 85 69 </T.Mesh> 86 70 87 - {#each roomState.objects as object} 71 + {#each roomState.objects as object (object.kind + object.position[0].toFixed(2) + object.position[1].toFixed(2) + object.position[2].toFixed(2))} 88 72 {#if editorState.selectedObject === object} 89 73 <TransformControls 90 74 bind:controls={editorState.transformControls} ··· 107 91 {:else if editorState.isEditing} 108 92 <T.Group 109 93 onclick={(evt: { stopPropagation: () => void }) => { 94 + if(editorState.placingObject) return; 95 + 110 96 evt.stopPropagation(); 111 97 112 98 applyTransformOfSelected();
+28
src/lib/state.svelte.ts
··· 92 92 } 93 93 } 94 94 95 + export function rotateRight() { 96 + rotateObject(Math.PI / 6); 97 + } 98 + 99 + export function rotateLeft() { 100 + rotateObject(-Math.PI / 6); 101 + } 102 + 95 103 export function tryLoadingRoomFromLocalStorage(otherWiseReset: boolean = false) { 96 104 const room = localStorage.getItem('roomState'); 97 105 if (room) { ··· 110 118 roomState.version = currentVersion; 111 119 } 112 120 } 121 + 122 + export function deleteSelectedObject() { 123 + roomState.objects = roomState.objects.filter((object) => object !== editorState.selectedObject); 124 + 125 + editorState.selectedObject = null; 126 + 127 + saveRoomToLocalStorage(); 128 + } 129 + 130 + export function placeObject(point: { x: number; y: number; z: number }) { 131 + if (editorState.placingObject) { 132 + roomState.objects.push(editorState.placingObject); 133 + editorState.placingObject.position = [point.x, 0, point.z]; 134 + editorState.selectedObject = editorState.placingObject; 135 + 136 + editorState.placingObject = null; 137 + 138 + saveRoomToLocalStorage(); 139 + } 140 + }
+130
src/lib/ui-state.svelte.ts
··· 1 + import { toast } from 'svelte-sonner'; 2 + import { AllObjects } from './models'; 3 + import { client, getRoom, resolveHandle } from './oauth/auth.svelte'; 4 + import { 5 + applyTransformOfSelected, 6 + editorState, 7 + roomState, 8 + type RoomObjectData 9 + } from './state.svelte'; 10 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 11 + 12 + export const modals = $state({ 13 + selectCategoryModalOpen: false, 14 + roomSettingsModalState: false, 15 + linkModalState: false, 16 + infoModalState: false, 17 + successModalState: false, 18 + signInSuccessModalState: false 19 + }); 20 + 21 + export const userInfo: { 22 + handle: string; 23 + did: string; 24 + profile: ProfileViewDetailed | null; 25 + } = $state({ 26 + handle: '', 27 + did: '', 28 + profile: null 29 + }); 30 + 31 + export async function loadRoomFromBluesky(handle: string) { 32 + const did = await resolveHandle({ handle }); 33 + console.log(did); 34 + const room = await getRoom({ did }); 35 + console.log(room); 36 + 37 + roomState.wallColor = room.room.wallColor; 38 + roomState.floorColor = room.room.floorColor; 39 + 40 + const availableObjects = new Set(Object.keys(AllObjects)); 41 + 42 + roomState.objects = room.room.objects.filter((object: RoomObjectData) => 43 + availableObjects.has(object.kind as string) 44 + ); 45 + if (roomState.objects.length !== room.room.objects.length) { 46 + console.warn('some objects were not found and removed!'); 47 + } 48 + 49 + roomState.size = room.room.size; 50 + roomState.id = room.room.id; 51 + roomState.version = room.room.version; 52 + 53 + // @ts-expect-error umami is not defined in the global scope 54 + if (window.umami) { 55 + // @ts-expect-error umami is not defined in the global scope 56 + window.umami.track('room_view', { 57 + handle: handle 58 + }); 59 + } 60 + return room.profile; 61 + } 62 + 63 + export async function saveRoomToBluesky() { 64 + if (!client.rpc || !client.profile) { 65 + toast.error('Failed to save room, please log in first'); 66 + return; 67 + } 68 + 69 + applyTransformOfSelected(); 70 + 71 + editorState.selectedObject = null; 72 + 73 + try { 74 + // @ts-expect-error umami is not defined in the global scope 75 + if (window.umami) { 76 + // @ts-expect-error umami is not defined in the global scope 77 + window.umami?.track('room_save_try', { 78 + handle: client.profile.handle, 79 + did: client.profile.did, 80 + objectCount: roomState.objects.length 81 + }); 82 + } 83 + 84 + await client.rpc.call('com.atproto.repo.putRecord', { 85 + data: { 86 + collection: 'dev.flo-bit.room', 87 + repo: client.profile.did, 88 + rkey: 'self', 89 + record: { 90 + roomState: JSON.parse( 91 + JSON.stringify({ 92 + wallColor: roomState.wallColor, 93 + floorColor: roomState.floorColor, 94 + objects: roomState.objects, 95 + size: roomState.size, 96 + id: roomState.id, 97 + version: roomState.version 98 + }) 99 + ) 100 + } 101 + } 102 + }); 103 + 104 + // @ts-expect-error umami is not defined in the global scope 105 + if (window.umami) { 106 + // @ts-expect-error umami is not defined in the global scope 107 + window.umami?.track('room_save_success', { 108 + handle: client.profile.handle, 109 + did: client.profile.did, 110 + objectCount: roomState.objects.length 111 + }); 112 + console.log('saved room_save'); 113 + } 114 + 115 + modals.successModalState = true; 116 + 117 + toast.success('Room saved to your profile'); 118 + } catch (e) { 119 + console.error(e); 120 + // @ts-expect-error umami is not defined in the global scope 121 + if (window.umami) { 122 + // @ts-expect-error umami is not defined in the global scope 123 + window.umami?.track('room_save_error', { 124 + handle: client.profile.handle, 125 + did: client.profile.did 126 + }); 127 + } 128 + toast.error('Failed to save room, please try again'); 129 + } 130 + }
+17 -759
src/routes/+page.svelte
··· 2 2 import { Canvas } from '@threlte/core'; 3 3 import Scene from '$lib/Scene.svelte'; 4 4 import { onMount } from 'svelte'; 5 - import { client, getRoom, resolveHandle } from '$lib/oauth/auth.svelte'; 6 - import { 7 - editorState, 8 - roomState, 9 - applyTransformOfSelected, 10 - rotateObject, 11 - type RoomObjectData, 12 - saveRoomToLocalStorage, 13 - tryLoadingRoomFromLocalStorage 14 - } from '$lib/state.svelte'; 5 + import { client } from '$lib/oauth/auth.svelte'; 6 + import { deleteSelectedObject, editorState, rotateLeft, rotateRight, tryLoadingRoomFromLocalStorage } from '$lib/state.svelte'; 15 7 import { Button } from '$lib/components/base/button'; 16 - import { blueskyLoginModalState } from '$lib/components/base/modal/BlueskyLoginModal.svelte'; 17 - import NumberInput from '$lib/components/base/number-input/NumberInput.svelte'; 18 8 import Modal from '$lib/components/base/modal/Modal.svelte'; 19 9 import Heading from '$lib/components/base/heading/Heading.svelte'; 20 - import Subheading from '$lib/components/base/heading/Subheading.svelte'; 21 - 22 - import { toast } from 'svelte-sonner'; 23 - 24 - import * as Popover from '$lib/components/base/popover'; 25 - import { cn } from '$lib/utils'; 26 - import ColorPicker from '$lib/components/extra/color-picker'; 27 - import { 28 - hex_to_rgb, 29 - okhsv_to_rgb, 30 - rgb_to_hex, 31 - rgb_to_okhsv 32 - } from '$lib/components/extra/color-picker/color'; 33 10 import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 34 - import Avatar from '$lib/components/base/avatar/Avatar.svelte'; 35 - import ColorPickerPopover from './ColorPickerPopover.svelte'; 36 - import { AllObjects, visibleKeys } from '$lib/models'; 37 - import Picker from './Picker.svelte'; 38 - import { Input } from '$lib/components/base/input'; 39 11 40 - let saving = $state(false); 41 - async function saveRoomToBluesky() { 42 - if (!client.rpc || !client.profile) { 43 - toast.error('Failed to save room, please log in first'); 44 - return; 45 - } 46 - 47 - saving = true; 48 - 49 - applyTransformOfSelected(); 50 - 51 - editorState.selectedObject = null; 52 - 53 - try { 54 - // @ts-ignore 55 - if (window.umami) { 56 - // @ts-ignore 57 - window.umami?.track('room_save', { 58 - handle: client.profile.handle, 59 - did: client.profile.did, 60 - objectCount: roomState.objects.length 61 - }); 62 - } 63 - 64 - const hello = await client.rpc.call('com.atproto.repo.putRecord', { 65 - data: { 66 - collection: 'dev.flo-bit.room', 67 - repo: client.profile.did, 68 - rkey: 'self', 69 - record: { 70 - roomState: JSON.parse( 71 - JSON.stringify({ 72 - wallColor: roomState.wallColor, 73 - floorColor: roomState.floorColor, 74 - objects: roomState.objects, 75 - size: roomState.size, 76 - id: roomState.id, 77 - version: roomState.version 78 - }) 79 - ) 80 - } 81 - } 82 - }); 83 - 84 - // @ts-ignore 85 - if (window.umami) { 86 - // @ts-ignore 87 - window.umami?.track('room_save', { 88 - handle: client.profile.handle, 89 - objectCount: roomState.objects.length 90 - }); 91 - console.log('saved room_save'); 92 - } 93 - 94 - // TODO: show success modal 95 - successModalState = true; 96 - 97 - toast.success('Room saved to your profile'); 98 - } catch (e) { 99 - console.error(e); 100 - toast.error('Failed to save room, please try again'); 101 - } 102 - 103 - saving = false; 104 - } 105 - 106 - let profile: ProfileViewDetailed | null = $state(null); 12 + import EditorUi from './EditorUI.svelte'; 13 + import PreviewUi from './PreviewUI.svelte'; 14 + import { loadRoomFromBluesky, modals, userInfo } from '$lib/ui-state.svelte'; 107 15 108 16 let loading = $state(true); 109 17 110 - let currentHandle = $state('flo-bit.dev'); 111 - 112 - let successModalState = $state(false); 113 - 114 - let signInSuccessModalState = $state(false); 115 - 116 - async function loadRoomFromBluesky(handle: string) { 117 - const did = await resolveHandle({ handle }); 118 - console.log(did); 119 - const room = await getRoom({ did }); 120 - console.log(room); 121 - 122 - roomState.wallColor = room.room.wallColor; 123 - roomState.floorColor = room.room.floorColor; 124 - 125 - let availableObjects = new Set(Object.keys(AllObjects)); 126 - 127 - roomState.objects = room.room.objects.filter((object: RoomObjectData) => 128 - availableObjects.has(object.kind as string) 129 - ); 130 - if (roomState.objects.length !== room.room.objects.length) { 131 - console.warn('some objects were not found and removed!'); 132 - } 133 - 134 - roomState.size = room.room.size; 135 - roomState.id = room.room.id; 136 - roomState.version = room.room.version; 137 - 138 - profile = room.profile; 139 - 140 - // @ts-ignore 141 - if (window.umami) { 142 - // @ts-ignore 143 - window.umami.track('room_view', { 144 - handle: handle 145 - }); 146 - } 147 - } 148 - 149 18 onMount(async () => { 150 - if (client.justLoggedIn) { 151 - successModalState = true; 152 - client.justLoggedIn = false; 153 - } 154 - 155 19 window.addEventListener('keydown', (e) => { 156 20 if (e.key === 'x') { 157 21 deleteSelectedObject(); 22 + } 23 + if (e.key === 'd' || e.key === 'ArrowRight') { 24 + rotateRight(); 25 + } else if (e.key === 'a' || e.key === 'ArrowLeft') { 26 + rotateLeft(); 158 27 } 159 28 }); 160 29 ··· 163 32 if (!handle) { 164 33 tryLoadingRoomFromLocalStorage(); 165 34 loading = false; 166 - currentHandle = ''; 35 + userInfo.handle = ''; 167 36 editorState.isEditing = true; 168 37 return; 169 38 } 170 39 171 - currentHandle = handle; 40 + userInfo.handle = handle; 172 41 173 42 await loadRoomFromBluesky(handle); 174 43 175 44 loading = false; 176 45 }); 177 46 178 - let roomSettingsModalState = $state(false); 179 - let createRoomModalState = $state(false); 180 - let infoModalState = $state(false); 181 - 182 - let okhsv = $state({ h: 180, s: 0.0, v: 0.95 }); 183 - let okhsv2 = $state({ h: 180, s: 0.0, v: 0.95 }); 184 - let okhsv3 = $state({ h: 180, s: 0.0, v: 0.95 }); 185 - let okhsv4 = $state({ h: 180, s: 0.0, v: 0.95 }); 186 - let okhsv5 = $state({ h: 180, s: 0.0, v: 0.95 }); 187 - 188 - let okhsv_wall = $state({ h: 180, s: 0.0, v: 0.95 }); 189 - let okhsv_floor = $state({ h: 180, s: 0.0, v: 0.95 }); 190 - 191 - function deleteSelectedObject() { 192 - roomState.objects = roomState.objects.filter((object) => object !== editorState.selectedObject); 193 - 194 - editorState.selectedObject = null; 195 - } 196 - 197 - $effect(() => { 198 - if (!editorState.selectedObject) return; 199 - 200 - okhsv = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[0])); 201 - okhsv2 = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[1])); 202 - okhsv3 = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[2])); 203 - 204 - okhsv_wall = rgb_to_okhsv(hex_to_rgb(roomState.wallColor)); 205 - okhsv_floor = rgb_to_okhsv(hex_to_rgb(roomState.floorColor)); 206 - }); 207 - 208 47 $effect(() => { 209 48 if (client.justLoggedIn) { 210 - signInSuccessModalState = true; 49 + modals.signInSuccessModalState = true; 211 50 client.justLoggedIn = false; 212 51 } 213 52 }); 214 - 215 - let lastUsedColors = $derived.by(() => { 216 - let colors = []; 217 - 218 - for (const object of roomState.objects.toReversed()) { 219 - for (const color of object.colors) { 220 - colors.push(color); 221 - } 222 - } 223 - // filter out default colors (#f1f1f1, #a1a1a1) 224 - colors = colors.filter((color, index, self) => { 225 - return color !== '#f1f1f1' && color !== '#a1a1a1'; 226 - }); 227 - 228 - // filter out duplicates 229 - colors = Array.from(new Set(colors)); 230 - 231 - // return first 14 colors 232 - return colors.slice(0, 14); 233 - }); 234 - 235 - let selectCategoryModalOpen = $state(false); 236 - 237 - let linkModalState = $state(false); 238 - 239 - let link = $state(''); 240 53 </script> 241 54 242 55 <div class="fixed inset-0 -z-20 h-[100dvh] w-screen"> ··· 246 59 </div> 247 60 248 61 {#if editorState.isEditing} 249 - <Button class="fixed bottom-4 left-4 -z-10" size="iconLg" onclick={() => { 250 - selectCategoryModalOpen = true; 251 - }}> 252 - <svg 253 - xmlns="http://www.w3.org/2000/svg" 254 - fill="none" 255 - viewBox="0 0 24 24" 256 - stroke-width="2.5" 257 - stroke="currentColor" 258 - > 259 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 260 - </svg> 261 - </Button> 262 - 263 - <div class="fixed top-4 left-4 flex flex-col items-start gap-2 -z-10"> 264 - <Button 265 - size="iconLg" 266 - onclick={() => { 267 - roomSettingsModalState = true; 268 - }} 269 - > 270 - <svg 271 - xmlns="http://www.w3.org/2000/svg" 272 - viewBox="0 0 24 24" 273 - fill="currentColor" 274 - class="size-6" 275 - > 276 - <path 277 - fill-rule="evenodd" 278 - d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 0 0-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 0 0-2.282.819l-.922 1.597a1.875 1.875 0 0 0 .432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 0 0 0 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 0 0-.432 2.385l.922 1.597a1.875 1.875 0 0 0 2.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 0 0 2.28-.819l.923-1.597a1.875 1.875 0 0 0-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 0 0 0-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 0 0-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 0 0-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 0 0-1.85-1.567h-1.843ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z" 279 - clip-rule="evenodd" 280 - /> 281 - </svg> 282 - </Button> 283 - 284 - {#if editorState.selectedObject} 285 - <Button 286 - variant="red" 287 - size="icon" 288 - class="mt-8" 289 - onclick={() => { 290 - roomState.objects = roomState.objects.filter( 291 - (object) => object !== editorState.selectedObject 292 - ); 293 - 294 - editorState.selectedObject = null; 295 - }} 296 - > 297 - <svg 298 - xmlns="http://www.w3.org/2000/svg" 299 - viewBox="0 0 24 24" 300 - fill="currentColor" 301 - class="size-6" 302 - > 303 - <path 304 - fill-rule="evenodd" 305 - d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z" 306 - clip-rule="evenodd" 307 - /> 308 - </svg> 309 - 310 - <span class="sr-only">Delete selected object</span> 311 - </Button> 312 - 313 - <div class="mt-4"> 314 - <Button 315 - size="icon" 316 - onclick={() => { 317 - rotateObject(-Math.PI / 8); 318 - }} 319 - variant="secondary" 320 - > 321 - <svg 322 - xmlns="http://www.w3.org/2000/svg" 323 - fill="none" 324 - viewBox="0 0 24 24" 325 - stroke-width="1.5" 326 - stroke="currentColor" 327 - class="-scale-x-100" 328 - > 329 - <path 330 - stroke-linecap="round" 331 - stroke-linejoin="round" 332 - d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 333 - /> 334 - </svg> 335 - </Button> 336 - 337 - <Button 338 - size="icon" 339 - onclick={() => { 340 - rotateObject(Math.PI / 8); 341 - }} 342 - variant="secondary" 343 - > 344 - <svg 345 - xmlns="http://www.w3.org/2000/svg" 346 - fill="none" 347 - viewBox="0 0 24 24" 348 - stroke-width="1.5" 349 - stroke="currentColor" 350 - class="" 351 - > 352 - <path 353 - stroke-linecap="round" 354 - stroke-linejoin="round" 355 - d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 356 - /> 357 - </svg> 358 - </Button> 359 - </div> 360 - 361 - <ColorPickerPopover 362 - {lastUsedColors} 363 - bind:okhsv 364 - hex={editorState.selectedObject.colors[0]} 365 - onChange={(hex) => { 366 - if (!editorState.selectedObject) return; 367 - 368 - editorState.selectedObject.colors[0] = hex; 369 - saveRoomToLocalStorage(); 370 - }} 371 - /> 372 - 373 - {#if AllObjects[editorState.selectedObject.kind].colors > 1} 374 - <ColorPickerPopover 375 - {lastUsedColors} 376 - bind:okhsv={okhsv2} 377 - hex={editorState.selectedObject.colors[1]} 378 - onChange={(hex) => { 379 - if (!editorState.selectedObject) return; 380 - 381 - editorState.selectedObject.colors[1] = hex; 382 - saveRoomToLocalStorage(); 383 - }} 384 - /> 385 - {/if} 386 - 387 - {#if AllObjects[editorState.selectedObject.kind].colors > 2} 388 - <ColorPickerPopover 389 - {lastUsedColors} 390 - bind:okhsv={okhsv3} 391 - hex={editorState.selectedObject.colors[2]} 392 - onChange={(hex) => { 393 - if (!editorState.selectedObject) return; 394 - 395 - editorState.selectedObject.colors[2] = hex; 396 - saveRoomToLocalStorage(); 397 - }} 398 - /> 399 - {/if} 400 - 401 - {#if AllObjects[editorState.selectedObject.kind].colors > 3} 402 - <ColorPickerPopover 403 - {lastUsedColors} 404 - bind:okhsv={okhsv4} 405 - hex={editorState.selectedObject.colors[3]} 406 - onChange={(hex) => { 407 - if (!editorState.selectedObject) return; 408 - 409 - editorState.selectedObject.colors[3] = hex; 410 - saveRoomToLocalStorage(); 411 - }} 412 - /> 413 - {/if} 414 - 415 - {#if AllObjects[editorState.selectedObject.kind].colors > 4} 416 - <ColorPickerPopover 417 - {lastUsedColors} 418 - bind:okhsv={okhsv5} 419 - hex={editorState.selectedObject.colors[4]} 420 - onChange={(hex) => { 421 - if (!editorState.selectedObject) return; 422 - 423 - editorState.selectedObject.colors[4] = hex; 424 - saveRoomToLocalStorage(); 425 - }} 426 - /> 427 - {/if} 428 - 429 - {#if AllObjects[editorState.selectedObject.kind].link} 430 - <Button 431 - size="icon" 432 - onclick={() => { 433 - linkModalState = true; 434 - }} 435 - class="m-1" 436 - > 437 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 438 - <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> 439 - </svg> 440 - 441 - </Button> 442 - {/if} 443 - {/if} 444 - </div> 445 - <div class="fixed top-4 right-4 flex flex-col items-end gap-2 -z-10"> 446 - {#if !client || !client.isLoggedIn} 447 - <Button 448 - onclick={() => { 449 - applyTransformOfSelected(); 450 - 451 - blueskyLoginModalState.open = true; 452 - }} 453 - > 454 - <svg 455 - role="img" 456 - viewBox="0 0 24 24" 457 - xmlns="http://www.w3.org/2000/svg" 458 - aria-hidden="true" 459 - fill="currentColor" 460 - ><path 461 - d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 462 - /></svg 463 - > 464 - Login</Button 465 - > 466 - {:else} 467 - <Button 468 - onclick={() => { 469 - saveRoomToBluesky(); 470 - }}>Save & Share</Button 471 - > 472 - {/if} 473 - 474 - <Popover.Root> 475 - <Popover.Trigger class={cn('mt-5 ml-1 cursor-pointer')}> 476 - <div 477 - class="border-base-300 dark:border-base-700 z-10 size-8 rounded-full border" 478 - style={`background-color: ${roomState.wallColor};`} 479 - ></div> 480 - </Popover.Trigger> 481 - <Popover.Content side="right" sideOffset={10}> 482 - <ColorPicker 483 - bind:okhsv={okhsv_wall} 484 - on:change={(e) => { 485 - const rgb = okhsv_to_rgb(okhsv_wall); 486 - roomState.wallColor = rgb_to_hex(rgb); 487 - saveRoomToLocalStorage(); 488 - }} 489 - /> 490 - </Popover.Content> 491 - </Popover.Root> 492 - 493 - <Popover.Root> 494 - <Popover.Trigger class={cn('mt-5 ml-1 cursor-pointer')}> 495 - <div 496 - class="border-base-300 dark:border-base-700 z-10 size-8 rounded-full border" 497 - style={`background-color: ${roomState.floorColor};`} 498 - ></div> 499 - </Popover.Trigger> 500 - <Popover.Content side="right" sideOffset={10}> 501 - <ColorPicker 502 - bind:okhsv={okhsv_floor} 503 - on:change={(e) => { 504 - const rgb = okhsv_to_rgb(okhsv_floor); 505 - roomState.floorColor = rgb_to_hex(rgb); 506 - saveRoomToLocalStorage(); 507 - }} 508 - /> 509 - </Popover.Content> 510 - </Popover.Root> 511 - </div> 512 - 513 - <Modal bind:open={roomSettingsModalState}> 514 - <div class="flex flex-col items-start gap-4"> 515 - <Heading>Room settings</Heading> 516 - 517 - <Button 518 - variant="secondary" 519 - class="mt-4" 520 - onclick={() => { 521 - applyTransformOfSelected(); 522 - editorState.selectedObject = null; 523 - currentHandle = client.profile?.handle ?? ''; 524 - profile = client.profile; 525 - editorState.isEditing = false; 526 - roomSettingsModalState = false; 527 - }} 528 - > 529 - Stop editing & Preview 530 - </Button> 531 - 532 - <Subheading class="mt-2">Room Size</Subheading> 533 - <div class="flex items-center gap-2"> 534 - <NumberInput 535 - bind:value={roomState.size.x} 536 - min={1} 537 - max={8} 538 - onchange={() => saveRoomToLocalStorage()} 539 - /> 540 - <span class="text-accent-700 dark:text-accent-300"> 541 - <svg 542 - xmlns="http://www.w3.org/2000/svg" 543 - fill="none" 544 - viewBox="0 0 24 24" 545 - stroke-width="3" 546 - stroke="currentColor" 547 - class="size-4" 548 - > 549 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 550 - </svg> 551 - </span> 552 - <NumberInput 553 - bind:value={roomState.size.z} 554 - min={1} 555 - max={8} 556 - onchange={() => saveRoomToLocalStorage()} 557 - /> 558 - </div> 559 - 560 - <Button 561 - class="mt-6" 562 - variant="secondary" 563 - onclick={async () => { 564 - const json = JSON.stringify(roomState, null, 2); 565 - const blob = new Blob([json], { type: 'application/json' }); 566 - const url = URL.createObjectURL(blob); 567 - const a = document.createElement('a'); 568 - a.href = url; 569 - a.download = 'room.json'; 570 - a.click(); 571 - }} 572 - > 573 - Export as json 574 - </Button> 575 - <Subheading class="mt-2">Danger zone</Subheading> 576 - <div class="mt-4 flex flex-wrap gap-2"> 577 - <Button 578 - variant="red" 579 - onclick={() => { 580 - const file = document.createElement('input'); 581 - file.type = 'file'; 582 - file.accept = '.json'; 583 - file.onchange = (e) => { 584 - // @ts-ignore 585 - if (!e.target?.files) { 586 - console.error('no files found'); 587 - return; 588 - } 589 - // @ts-ignore 590 - const file = e.target.files[0]; 591 - const reader = new FileReader(); 592 - reader.onload = (e) => { 593 - if (!e.target?.result) { 594 - console.error('no result found'); 595 - return; 596 - } 597 - 598 - const json = JSON.parse(e.target.result as string); 599 - roomState.floorColor = json.floorColor; 600 - roomState.wallColor = json.wallColor; 601 - roomState.objects = json.objects; 602 - roomState.size = json.size; 603 - roomState.id = json.id; 604 - roomState.version = json.version; 605 - 606 - saveRoomToLocalStorage(); 607 - }; 608 - reader.readAsText(file); 609 - }; 610 - file.click(); 611 - }} 612 - > 613 - Import from json 614 - </Button> 615 - 616 - {#if client.isLoggedIn} 617 - <Button 618 - variant="red" 619 - onclick={async () => { 620 - if (!client.profile?.handle) { 621 - console.error('no handle found'); 622 - return; 623 - } 624 - await loadRoomFromBluesky(client.profile.handle); 625 - roomSettingsModalState = false; 626 - }} 627 - > 628 - Load from bluesky 629 - </Button> 630 - {/if} 631 - 632 - <Button 633 - variant="red" 634 - onclick={() => { 635 - roomState.objects = []; 636 - editorState.selectedObject = null; 637 - roomState.size = { x: 2, z: 3 }; 638 - roomState.wallColor = '#f1f1f1'; 639 - roomState.floorColor = '#a1a1a1'; 640 - 641 - saveRoomToLocalStorage(); 642 - roomSettingsModalState = false; 643 - }} 644 - > 645 - Clear room 646 - </Button> 647 - </div> 648 - </div> 649 - </Modal> 650 - 651 - <Modal bind:open={linkModalState}> 652 - <div class="flex flex-col gap-4"> 653 - <Subheading class="mb-4">Enter a youtube link:</Subheading> 654 - <Input bind:value={link} placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> 655 - <Button onclick={() => { 656 - linkModalState = false; 657 - if (!link || !editorState.selectedObject) return; 658 - 659 - editorState.selectedObject.link = link; 660 - }}>Save</Button> 661 - </div> 662 - </Modal> 62 + <EditorUi /> 663 63 {:else if !loading} 664 - <div 665 - class="pointer-events-none fixed top-8 right-4 left-4 flex items-center justify-center gap-2" 666 - > 667 - {#if currentHandle} 668 - <Avatar src={profile?.avatar} /> 669 - {/if} 670 - <Heading> 671 - {#if currentHandle} 672 - <a 673 - href={`https://bsky.app/profile/${profile?.handle}`} 674 - target="_blank" 675 - class="hover:text-accent-600 dark:hover:text-accent-500 pointer-events-auto" 676 - >{currentHandle}'s</a 677 - > 678 - {:else} 679 - my 680 - {/if} 681 - room</Heading 682 - > 683 - </div> 684 - 685 - <div class="pointer-events-none fixed right-4 bottom-4 left-4 flex justify-between"> 686 - <Button 687 - size="lg" 688 - onclick={() => { 689 - infoModalState = true; 690 - }} 691 - variant="secondary" 692 - > 693 - Info 694 - </Button> 695 - 696 - <Button 697 - size="lg" 698 - onclick={() => { 699 - if (client.profile?.handle == currentHandle) { 700 - editorState.isEditing = true; 701 - return; 702 - } 703 - 704 - tryLoadingRoomFromLocalStorage(true); 705 - 706 - editorState.isEditing = true; 707 - }} 708 - > 709 - {client.profile?.handle == currentHandle ? 'Edit room' : 'Create room'} 710 - </Button> 711 - </div> 64 + <PreviewUi /> 712 65 {:else} 713 66 <div 714 67 class="bg-base-100 dark:bg-base-950 fixed inset-0 flex h-[100dvh] w-screen items-center justify-center" ··· 717 70 </div> 718 71 {/if} 719 72 720 - <Modal bind:open={infoModalState} class="text-base-900 dark:text-base-50"> 721 - <div> 722 - This is an experimental web app for creating tiny 3d rooms (and sharing them on bluesky) built 723 - by 724 - <a 725 - href="https://flo-bit.dev" 726 - target="_blank" 727 - class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 728 - >flo-bit</a 729 - > 730 - </div> 731 - <div> 732 - It's still a work in progress, but it's open source (<a 733 - href="https://github.com/flo-bit/room" 734 - target="_blank" 735 - class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 736 - >source code available here</a 737 - >), so feel free to contribute or suggest features on github 😊 738 - </div> 739 - 740 - <div> 741 - You can also send me a message on <a 742 - href="https://bsky.app/profile/flo-bit.dev" 743 - target="_blank" 744 - class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 745 - >bluesky</a 746 - > (or tag me when sharing your room so I can admire your creation 😊) 747 - </div> 748 - </Modal> 749 - 750 - <Modal bind:open={createRoomModalState}></Modal> 751 - 752 - <Modal bind:open={successModalState}> 753 - <Heading class="flex items-center gap-4"> 754 - <svg 755 - xmlns="http://www.w3.org/2000/svg" 756 - fill="none" 757 - viewBox="0 0 24 24" 758 - stroke-width="1.5" 759 - stroke="currentColor" 760 - class="text-accent-600 dark:text-accent-400 size-8" 761 - > 762 - <path 763 - stroke-linecap="round" 764 - stroke-linejoin="round" 765 - d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 766 - /> 767 - </svg> 768 - 769 - Room saved to your profile</Heading 770 - > 771 - <Button 772 - size="lg" 773 - class="mt-4 py-3" 774 - href={'https://bsky.app/intent/compose?text=' + 775 - encodeURIComponent( 776 - `Check out my tiny room: https://flo-bit.dev/room/?handle=${client.profile?.handle}` 777 - )} 778 - target="_blank" 779 - > 780 - <svg 781 - role="img" 782 - viewBox="0 0 24 24" 783 - xmlns="http://www.w3.org/2000/svg" 784 - aria-hidden="true" 785 - fill="currentColor" 786 - ><path 787 - d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 788 - /></svg 789 - > 790 - Share on Bluesky</Button 791 - > 792 - 793 - <Button 794 - variant="secondary" 795 - class="py-3" 796 - onclick={() => { 797 - navigator.clipboard.writeText(`https://flo-bit.dev/room/?handle=${client.profile?.handle}`); 798 - toast.success('Share link copied to clipboard'); 799 - }} 800 - > 801 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 802 - <path 803 - fill-rule="evenodd" 804 - d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.757a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1-.353 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 1 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.354-1Z" 805 - clip-rule="evenodd" 806 - /> 807 - </svg> 808 - 809 - Copy share link</Button 810 - > 811 - </Modal> 812 - 813 - <Modal bind:open={signInSuccessModalState}> 73 + <Modal bind:open={modals.signInSuccessModalState}> 814 74 <Heading class="flex items-center gap-4"> 815 75 <svg 816 76 role="img" ··· 848 108 tryLoadingRoomFromLocalStorage(true); 849 109 850 110 editorState.isEditing = true; 851 - signInSuccessModalState = false; 111 + modals.signInSuccessModalState = false; 852 112 }} 853 113 > 854 114 Go to my room 855 115 </Button> 856 116 </div> 857 117 </Modal> 858 - 859 - <Picker bind:selectCategoryModalOpen={selectCategoryModalOpen} />
+538
src/routes/EditorUI.svelte
··· 1 + <script lang="ts"> 2 + 3 + import { Button } from "$lib/components/base/button"; 4 + import { AllObjects } from "$lib/models"; 5 + import { client } from "$lib/oauth"; 6 + import { applyTransformOfSelected, deleteSelectedObject, editorState, roomState, rotateLeft, rotateObject, rotateRight, saveRoomToLocalStorage } from "$lib/state.svelte"; 7 + import ColorPickerPopover from "./ColorPickerPopover.svelte"; 8 + 9 + import * as Popover from "$lib/components/base/popover"; 10 + import { hex_to_rgb, okhsv_to_rgb, rgb_to_hex, rgb_to_okhsv } from "$lib/components/extra/color-picker/color"; 11 + import { ColorPicker } from "$lib/components/extra/color-picker"; 12 + import { cn } from "$lib/utils"; 13 + import Modal from "$lib/components/base/modal/Modal.svelte"; 14 + import { Heading, Subheading } from "$lib/components/base/heading"; 15 + import NumberInput from "$lib/components/base/number-input/NumberInput.svelte"; 16 + import { Input } from "$lib/components/base/input"; 17 + import { loadRoomFromBluesky, modals, saveRoomToBluesky, userInfo } from "$lib/ui-state.svelte"; 18 + import { blueskyLoginModalState } from "$lib/components/base/modal/BlueskyLoginModal.svelte"; 19 + import { toast } from "svelte-sonner"; 20 + import Picker from "./Picker.svelte"; 21 + 22 + let lastUsedColors = $derived.by(() => { 23 + let colors = []; 24 + 25 + for (const object of roomState.objects.toReversed()) { 26 + for (const color of object.colors) { 27 + colors.push(color); 28 + } 29 + } 30 + // filter out default colors (#f1f1f1, #a1a1a1) 31 + colors = colors.filter((color, index, self) => { 32 + return color !== '#f1f1f1' && color !== '#a1a1a1'; 33 + }); 34 + 35 + // filter out duplicates 36 + colors = Array.from(new Set(colors)); 37 + 38 + // return first 14 colors 39 + return colors.slice(0, 14); 40 + }); 41 + 42 + let okhsv = $state({ h: 180, s: 0.0, v: 0.95 }); 43 + let okhsv2 = $state({ h: 180, s: 0.0, v: 0.95 }); 44 + let okhsv3 = $state({ h: 180, s: 0.0, v: 0.95 }); 45 + let okhsv4 = $state({ h: 180, s: 0.0, v: 0.95 }); 46 + let okhsv5 = $state({ h: 180, s: 0.0, v: 0.95 }); 47 + 48 + let okhsv_wall = $state({ h: 180, s: 0.0, v: 0.95 }); 49 + let okhsv_floor = $state({ h: 180, s: 0.0, v: 0.95 }); 50 + 51 + let link = $state(''); 52 + 53 + $effect(() => { 54 + if (!editorState.selectedObject) return; 55 + 56 + okhsv = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[0])); 57 + okhsv2 = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[1])); 58 + okhsv3 = rgb_to_okhsv(hex_to_rgb(editorState.selectedObject.colors[2])); 59 + 60 + okhsv_wall = rgb_to_okhsv(hex_to_rgb(roomState.wallColor)); 61 + okhsv_floor = rgb_to_okhsv(hex_to_rgb(roomState.floorColor)); 62 + }); 63 + </script> 64 + 65 + <Button class="fixed bottom-4 left-4 -z-10" size="iconLg" onclick={() => { 66 + modals.selectCategoryModalOpen = true; 67 + }}> 68 + <svg 69 + xmlns="http://www.w3.org/2000/svg" 70 + fill="none" 71 + viewBox="0 0 24 24" 72 + stroke-width="2.5" 73 + stroke="currentColor" 74 + > 75 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 76 + </svg> 77 + </Button> 78 + 79 + <div class="fixed top-4 left-4 flex flex-col items-start gap-2 -z-10"> 80 + <Button 81 + size="iconLg" 82 + onclick={() => { 83 + modals.roomSettingsModalState = true; 84 + }} 85 + > 86 + <svg 87 + xmlns="http://www.w3.org/2000/svg" 88 + viewBox="0 0 24 24" 89 + fill="currentColor" 90 + class="size-6" 91 + > 92 + <path 93 + fill-rule="evenodd" 94 + d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 0 0-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 0 0-2.282.819l-.922 1.597a1.875 1.875 0 0 0 .432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 0 0 0 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 0 0-.432 2.385l.922 1.597a1.875 1.875 0 0 0 2.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 0 0 2.28-.819l.923-1.597a1.875 1.875 0 0 0-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 0 0 0-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 0 0-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 0 0-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 0 0-1.85-1.567h-1.843ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z" 95 + clip-rule="evenodd" 96 + /> 97 + </svg> 98 + </Button> 99 + 100 + {#if editorState.selectedObject} 101 + <Button 102 + variant="red" 103 + size="icon" 104 + class="mt-8" 105 + onclick={() => { 106 + deleteSelectedObject(); 107 + }} 108 + > 109 + <svg 110 + xmlns="http://www.w3.org/2000/svg" 111 + viewBox="0 0 24 24" 112 + fill="currentColor" 113 + class="size-6" 114 + > 115 + <path 116 + fill-rule="evenodd" 117 + d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z" 118 + clip-rule="evenodd" 119 + /> 120 + </svg> 121 + 122 + <span class="sr-only">Delete selected object</span> 123 + </Button> 124 + 125 + <div class="mt-4"> 126 + <Button 127 + size="icon" 128 + onclick={() => { 129 + rotateLeft(); 130 + }} 131 + variant="secondary" 132 + > 133 + <svg 134 + xmlns="http://www.w3.org/2000/svg" 135 + fill="none" 136 + viewBox="0 0 24 24" 137 + stroke-width="1.5" 138 + stroke="currentColor" 139 + class="-scale-x-100" 140 + > 141 + <path 142 + stroke-linecap="round" 143 + stroke-linejoin="round" 144 + d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 145 + /> 146 + </svg> 147 + </Button> 148 + 149 + <Button 150 + size="icon" 151 + onclick={() => { 152 + rotateRight(); 153 + }} 154 + variant="secondary" 155 + > 156 + <svg 157 + xmlns="http://www.w3.org/2000/svg" 158 + fill="none" 159 + viewBox="0 0 24 24" 160 + stroke-width="1.5" 161 + stroke="currentColor" 162 + class="" 163 + > 164 + <path 165 + stroke-linecap="round" 166 + stroke-linejoin="round" 167 + d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" 168 + /> 169 + </svg> 170 + </Button> 171 + </div> 172 + 173 + <ColorPickerPopover 174 + {lastUsedColors} 175 + bind:okhsv 176 + hex={editorState.selectedObject.colors[0]} 177 + onChange={(hex) => { 178 + if (!editorState.selectedObject) return; 179 + 180 + editorState.selectedObject.colors[0] = hex; 181 + saveRoomToLocalStorage(); 182 + }} 183 + /> 184 + 185 + {#if AllObjects[editorState.selectedObject.kind].colors > 1} 186 + <ColorPickerPopover 187 + {lastUsedColors} 188 + bind:okhsv={okhsv2} 189 + hex={editorState.selectedObject.colors[1]} 190 + onChange={(hex) => { 191 + if (!editorState.selectedObject) return; 192 + 193 + editorState.selectedObject.colors[1] = hex; 194 + saveRoomToLocalStorage(); 195 + }} 196 + /> 197 + {/if} 198 + 199 + {#if AllObjects[editorState.selectedObject.kind].colors > 2} 200 + <ColorPickerPopover 201 + {lastUsedColors} 202 + bind:okhsv={okhsv3} 203 + hex={editorState.selectedObject.colors[2]} 204 + onChange={(hex) => { 205 + if (!editorState.selectedObject) return; 206 + 207 + editorState.selectedObject.colors[2] = hex; 208 + saveRoomToLocalStorage(); 209 + }} 210 + /> 211 + {/if} 212 + 213 + {#if AllObjects[editorState.selectedObject.kind].colors > 3} 214 + <ColorPickerPopover 215 + {lastUsedColors} 216 + bind:okhsv={okhsv4} 217 + hex={editorState.selectedObject.colors[3]} 218 + onChange={(hex) => { 219 + if (!editorState.selectedObject) return; 220 + 221 + editorState.selectedObject.colors[3] = hex; 222 + saveRoomToLocalStorage(); 223 + }} 224 + /> 225 + {/if} 226 + 227 + {#if AllObjects[editorState.selectedObject.kind].colors > 4} 228 + <ColorPickerPopover 229 + {lastUsedColors} 230 + bind:okhsv={okhsv5} 231 + hex={editorState.selectedObject.colors[4]} 232 + onChange={(hex) => { 233 + if (!editorState.selectedObject) return; 234 + 235 + editorState.selectedObject.colors[4] = hex; 236 + saveRoomToLocalStorage(); 237 + }} 238 + /> 239 + {/if} 240 + 241 + {#if AllObjects[editorState.selectedObject.kind].link} 242 + <Button 243 + size="icon" 244 + onclick={() => { 245 + modals.linkModalState = true; 246 + }} 247 + class="m-1" 248 + > 249 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 250 + <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> 251 + </svg> 252 + 253 + </Button> 254 + {/if} 255 + {/if} 256 + </div> 257 + <div class="fixed top-4 right-4 flex flex-col items-end gap-2 -z-10"> 258 + {#if !client || !client.isLoggedIn} 259 + <Button 260 + onclick={() => { 261 + applyTransformOfSelected(); 262 + 263 + blueskyLoginModalState.open = true; 264 + }} 265 + > 266 + <svg 267 + role="img" 268 + viewBox="0 0 24 24" 269 + xmlns="http://www.w3.org/2000/svg" 270 + aria-hidden="true" 271 + fill="currentColor" 272 + ><path 273 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 274 + /></svg 275 + > 276 + Login</Button 277 + > 278 + {:else} 279 + <Button 280 + onclick={() => { 281 + saveRoomToBluesky(); 282 + }}>Save & Share</Button 283 + > 284 + {/if} 285 + 286 + <Popover.Root> 287 + <Popover.Trigger class={cn('mt-5 ml-1 cursor-pointer')}> 288 + <div 289 + class="border-base-300 dark:border-base-700 z-10 size-8 rounded-full border" 290 + style={`background-color: ${roomState.wallColor};`} 291 + ></div> 292 + </Popover.Trigger> 293 + <Popover.Content side="right" sideOffset={10}> 294 + <ColorPicker 295 + bind:okhsv={okhsv_wall} 296 + on:change={(e) => { 297 + const rgb = okhsv_to_rgb(okhsv_wall); 298 + roomState.wallColor = rgb_to_hex(rgb); 299 + saveRoomToLocalStorage(); 300 + }} 301 + /> 302 + </Popover.Content> 303 + </Popover.Root> 304 + 305 + <Popover.Root> 306 + <Popover.Trigger class={cn('mt-5 ml-1 cursor-pointer')}> 307 + <div 308 + class="border-base-300 dark:border-base-700 z-10 size-8 rounded-full border" 309 + style={`background-color: ${roomState.floorColor};`} 310 + ></div> 311 + </Popover.Trigger> 312 + <Popover.Content side="right" sideOffset={10}> 313 + <ColorPicker 314 + bind:okhsv={okhsv_floor} 315 + on:change={(e) => { 316 + const rgb = okhsv_to_rgb(okhsv_floor); 317 + roomState.floorColor = rgb_to_hex(rgb); 318 + saveRoomToLocalStorage(); 319 + }} 320 + /> 321 + </Popover.Content> 322 + </Popover.Root> 323 + </div> 324 + 325 + <Modal bind:open={modals.roomSettingsModalState}> 326 + <div class="flex flex-col items-start gap-4"> 327 + <Heading>Room settings</Heading> 328 + 329 + <Button 330 + variant="secondary" 331 + class="mt-4" 332 + onclick={() => { 333 + applyTransformOfSelected(); 334 + editorState.selectedObject = null; 335 + userInfo.handle = client.profile?.handle ?? ''; 336 + userInfo.profile = client.profile; 337 + editorState.isEditing = false; 338 + modals.roomSettingsModalState = false; 339 + }} 340 + > 341 + Stop editing & Preview 342 + </Button> 343 + 344 + <Subheading class="mt-2">Room Size</Subheading> 345 + <div class="flex items-center gap-2"> 346 + <NumberInput 347 + bind:value={roomState.size.x} 348 + min={1} 349 + max={8} 350 + onchange={() => saveRoomToLocalStorage()} 351 + /> 352 + <span class="text-accent-700 dark:text-accent-300"> 353 + <svg 354 + xmlns="http://www.w3.org/2000/svg" 355 + fill="none" 356 + viewBox="0 0 24 24" 357 + stroke-width="3" 358 + stroke="currentColor" 359 + class="size-4" 360 + > 361 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 362 + </svg> 363 + </span> 364 + <NumberInput 365 + bind:value={roomState.size.z} 366 + min={1} 367 + max={8} 368 + onchange={() => saveRoomToLocalStorage()} 369 + /> 370 + </div> 371 + 372 + <Button 373 + class="mt-6" 374 + variant="secondary" 375 + onclick={async () => { 376 + const json = JSON.stringify(roomState, null, 2); 377 + const blob = new Blob([json], { type: 'application/json' }); 378 + const url = URL.createObjectURL(blob); 379 + const a = document.createElement('a'); 380 + a.href = url; 381 + a.download = 'room.json'; 382 + a.click(); 383 + }} 384 + > 385 + Export as json 386 + </Button> 387 + <Subheading class="mt-2">Danger zone</Subheading> 388 + <div class="mt-4 flex flex-wrap gap-2"> 389 + <Button 390 + variant="red" 391 + onclick={() => { 392 + const file = document.createElement('input'); 393 + file.type = 'file'; 394 + file.accept = '.json'; 395 + file.onchange = (e) => { 396 + // @ts-ignore 397 + if (!e.target?.files) { 398 + console.error('no files found'); 399 + return; 400 + } 401 + // @ts-ignore 402 + const file = e.target.files[0]; 403 + const reader = new FileReader(); 404 + reader.onload = (e) => { 405 + if (!e.target?.result) { 406 + console.error('no result found'); 407 + return; 408 + } 409 + 410 + const json = JSON.parse(e.target.result as string); 411 + roomState.floorColor = json.floorColor; 412 + roomState.wallColor = json.wallColor; 413 + roomState.objects = json.objects; 414 + roomState.size = json.size; 415 + roomState.id = json.id; 416 + roomState.version = json.version; 417 + 418 + saveRoomToLocalStorage(); 419 + }; 420 + reader.readAsText(file); 421 + }; 422 + file.click(); 423 + }} 424 + > 425 + Import from json 426 + </Button> 427 + 428 + {#if client.isLoggedIn} 429 + <Button 430 + variant="red" 431 + onclick={async () => { 432 + if (!client.profile?.handle) { 433 + console.error('no handle found'); 434 + return; 435 + } 436 + await loadRoomFromBluesky(client.profile.handle); 437 + modals.roomSettingsModalState = false; 438 + }} 439 + > 440 + Load from bluesky 441 + </Button> 442 + {/if} 443 + 444 + <Button 445 + variant="red" 446 + onclick={() => { 447 + roomState.objects = []; 448 + editorState.selectedObject = null; 449 + roomState.size = { x: 2, z: 3 }; 450 + roomState.wallColor = '#f1f1f1'; 451 + roomState.floorColor = '#a1a1a1'; 452 + 453 + saveRoomToLocalStorage(); 454 + modals.roomSettingsModalState = false; 455 + }} 456 + > 457 + Clear room 458 + </Button> 459 + </div> 460 + </div> 461 + </Modal> 462 + 463 + <Modal bind:open={modals.linkModalState}> 464 + <div class="flex flex-col gap-4"> 465 + <Subheading class="mb-4">Enter a youtube link:</Subheading> 466 + <Input bind:value={link} placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> 467 + <Button onclick={() => { 468 + modals.linkModalState = false; 469 + if (!link || !editorState.selectedObject) return; 470 + 471 + editorState.selectedObject.link = link; 472 + }}>Save</Button> 473 + </div> 474 + </Modal> 475 + 476 + <Modal bind:open={modals.successModalState}> 477 + <Heading class="flex items-center gap-4"> 478 + <svg 479 + xmlns="http://www.w3.org/2000/svg" 480 + fill="none" 481 + viewBox="0 0 24 24" 482 + stroke-width="1.5" 483 + stroke="currentColor" 484 + class="text-accent-600 dark:text-accent-400 size-8" 485 + > 486 + <path 487 + stroke-linecap="round" 488 + stroke-linejoin="round" 489 + d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" 490 + /> 491 + </svg> 492 + 493 + Room saved to your profile</Heading 494 + > 495 + <Button 496 + size="lg" 497 + class="mt-4 py-3" 498 + href={'https://bsky.app/intent/compose?text=' + 499 + encodeURIComponent( 500 + `Check out my tiny room: https://flo-bit.dev/room/?handle=${client.profile?.handle}` 501 + )} 502 + target="_blank" 503 + > 504 + <svg 505 + role="img" 506 + viewBox="0 0 24 24" 507 + xmlns="http://www.w3.org/2000/svg" 508 + aria-hidden="true" 509 + fill="currentColor" 510 + ><path 511 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 512 + /></svg 513 + > 514 + Share on Bluesky</Button 515 + > 516 + 517 + <Button 518 + variant="secondary" 519 + class="py-3" 520 + onclick={() => { 521 + navigator.clipboard.writeText(`https://flo-bit.dev/room/?handle=${client.profile?.handle}`); 522 + toast.success('Share link copied to clipboard'); 523 + }} 524 + > 525 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 526 + <path 527 + fill-rule="evenodd" 528 + d="M19.902 4.098a3.75 3.75 0 0 0-5.304 0l-4.5 4.5a3.75 3.75 0 0 0 1.035 6.037.75.75 0 0 1-.646 1.353 5.25 5.25 0 0 1-1.449-8.45l4.5-4.5a5.25 5.25 0 1 1 7.424 7.424l-1.757 1.757a.75.75 0 1 1-1.06-1.06l1.757-1.757a3.75 3.75 0 0 0 0-5.304Zm-7.389 4.267a.75.75 0 0 1 1-.353 5.25 5.25 0 0 1 1.449 8.45l-4.5 4.5a5.25 5.25 0 1 1-7.424-7.424l1.757-1.757a.75.75 0 1 1 1.06 1.06l-1.757 1.757a3.75 3.75 0 1 0 5.304 5.304l4.5-4.5a3.75 3.75 0 0 0-1.035-6.037.75.75 0 0 1-.354-1Z" 529 + clip-rule="evenodd" 530 + /> 531 + </svg> 532 + 533 + Copy share link</Button 534 + > 535 + </Modal> 536 + 537 + 538 + <Picker bind:selectCategoryModalOpen={modals.selectCategoryModalOpen} />
+4 -4
src/routes/Picker.svelte
··· 93 93 {#key category} 94 94 <div 95 95 class={[ 96 - 'bg-base-200 dark:bg-base-800 relative mx-2 my-4 h-full min-h-[80dvh] overscroll-y-none rounded-2xl p-4 sm:mx-20 md:mt-20', 96 + 'bg-base-100 dark:bg-base-900 isolate relative mx-2 my-4 h-full min-h-[80dvh] overscroll-y-none rounded-2xl p-4 sm:mx-20 md:mt-20', 97 97 open ? 'block' : 'hidden' 98 98 ]} 99 99 > 100 100 <Button 101 101 size="iconLg" 102 - class="absolute top-4 left-4" 102 + class="absolute top-4 left-4 z-[100]" 103 103 onclick={() => { 104 104 open = false; 105 105 selectCategoryModalOpen = true; ··· 121 121 {#each items as item, i} 122 122 <button 123 123 id="item" 124 - class="hover:bg-base-100/20 dark:hover:bg-base-900/20 m-4 inline-block cursor-pointer rounded-2xl p-4 transition-all duration-300 hover:scale-105" 124 + class="hover:bg-base-200/40 dark:hover:bg-base-800/40 m-4 inline-block cursor-pointer rounded-2xl p-4 transition-all duration-300 hover:scale-105" 125 125 onclick={() => { 126 126 addObject(item.kind as RoomObjectKind); 127 127 open = false; 128 128 }} 129 129 > 130 130 <div class="aspect-square" bind:this={item.dom}></div> 131 - <div class="mt-2 text-[#888]">{humanReadableName(item.kind)}</div> 131 + <div class="text-base-700 dark:text-base-300 text-sm">{humanReadableName(item.kind)}</div> 132 132 </button> 133 133 {/each} 134 134 </div>
+89
src/routes/PreviewUI.svelte
··· 1 + <script> 2 + import { Avatar } from '$lib/components/base/avatar'; 3 + 4 + import { Button } from '$lib/components/base/button'; 5 + import { Heading } from '$lib/components/base/heading'; 6 + import Modal from '$lib/components/base/modal/Modal.svelte'; 7 + import { client } from '$lib/oauth'; 8 + import { editorState, tryLoadingRoomFromLocalStorage } from '$lib/state.svelte'; 9 + import { modals, userInfo } from '$lib/ui-state.svelte'; 10 + </script> 11 + 12 + <div class="pointer-events-none fixed top-8 right-4 left-4 flex items-center justify-center gap-2"> 13 + {#if userInfo.handle} 14 + <Avatar src={userInfo.profile?.avatar} /> 15 + {/if} 16 + <Heading> 17 + {#if userInfo.handle} 18 + <a 19 + href={`https://bsky.app/profile/${userInfo.handle}`} 20 + target="_blank" 21 + class="hover:text-accent-600 dark:hover:text-accent-500 pointer-events-auto" 22 + >{userInfo.handle}'s</a 23 + > 24 + {:else} 25 + my 26 + {/if} 27 + room</Heading 28 + > 29 + </div> 30 + 31 + <div class="pointer-events-none fixed right-4 bottom-4 left-4 flex justify-between"> 32 + <Button 33 + size="lg" 34 + onclick={() => { 35 + modals.infoModalState = true; 36 + }} 37 + variant="secondary" 38 + > 39 + Info 40 + </Button> 41 + 42 + <Button 43 + size="lg" 44 + onclick={() => { 45 + if (client.profile?.handle == userInfo.handle) { 46 + editorState.isEditing = true; 47 + return; 48 + } 49 + 50 + tryLoadingRoomFromLocalStorage(true); 51 + 52 + editorState.isEditing = true; 53 + }} 54 + > 55 + {client.profile?.handle == userInfo.handle ? 'Edit room' : 'Create room'} 56 + </Button> 57 + </div> 58 + 59 + 60 + 61 + <Modal bind:open={modals.infoModalState} class="text-base-900 dark:text-base-50"> 62 + <div> 63 + This is an experimental web app for creating tiny 3d rooms (and sharing them on bluesky) built 64 + by 65 + <a 66 + href="https://flo-bit.dev" 67 + target="_blank" 68 + class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 69 + >flo-bit</a 70 + > 71 + </div> 72 + <div> 73 + It's still a work in progress, but it's open source (<a 74 + href="https://github.com/flo-bit/room" 75 + target="_blank" 76 + class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 77 + >source code available here</a 78 + >), so feel free to contribute or suggest features on github 😊 79 + </div> 80 + 81 + <div> 82 + You can also send me a message on <a 83 + href="https://bsky.app/profile/flo-bit.dev" 84 + target="_blank" 85 + class="text-accent-700 dark:text-accent-400 hover:text-accent-600 dark:hover:text-accent-500 font-medium" 86 + >bluesky</a 87 + > (or tag me when sharing your room so I can admire your creation 😊) 88 + </div> 89 + </Modal>
+21 -13
src/routes/Scene.svelte
··· 1 1 <script lang="ts"> 2 2 import RoomObject from '$lib/RoomObject.svelte'; 3 - import { T, useTask, useThrelte } from '@threlte/core'; 4 - import { OrbitControls } from '@threlte/extras'; 5 - import { Color } from 'three'; 6 - import { Align } from '@threlte/extras'; 3 + import { T, useTask } from '@threlte/core'; 4 + import { interactivity } from '@threlte/extras'; 7 5 6 + interactivity(); 8 7 let { kind } = $props(); 9 8 10 - const { scene } = useThrelte(); 11 9 let rotation = $state(0); 12 10 13 11 useTask((delta) => { 14 - rotation += delta; 12 + if (rotating) { 13 + rotation += delta; 14 + } 15 15 }); 16 + 17 + let rotating = false; 16 18 </script> 17 19 18 - <T.PerspectiveCamera 19 - makeDefault 20 - position={[0, 0, 2]} 21 - fov={50} 22 - near={1} 23 - far={10} 20 + <T.PerspectiveCamera makeDefault position={[0, 0, 2]} fov={50} near={1} far={10} 24 21 ></T.PerspectiveCamera> 25 22 26 23 <T.HemisphereLight args={[0xaaaaaa, 0x444444, 1]} /> 27 24 <T.DirectionalLight args={[0xffffff, 1]} position={[1, 1, 1]} /> 28 25 29 - <RoomObject {kind} rotation={[0.5, rotation, 0]} scale={0.4} /> 26 + <RoomObject 27 + onpointerenter={() => { 28 + rotating = true; 29 + }} 30 + onpointerleave={() => { 31 + rotating = false; 32 + }} 33 + {kind} 34 + rotation={[0.5, rotation + 0.5, 0]} 35 + scale={0.4} 36 + colors={['#fbcfe8', '#f472b6', '#db2777', '#9d174d', '#500724']} 37 + />