From 804b5e9e8ab7f5dd3514b7602f95c2734c2fceb1 Mon Sep 17 00:00:00 2001 From: "Jakub Rocek - Musaro s.r.o." Date: Tue, 24 Jun 2025 00:30:34 +0000 Subject: [PATCH] feat: add views to embed viewer --- studio/app/api/embeds/route.ts | 31 +++++- studio/features/api-sdk/uploadEmbed.ts | 29 +++++- studio/features/db/entities/embed.ts | 17 ++++ ...0001-add-embed-saved-views-relationship.ts | 35 +++++++ .../components/EditorExportsCreate.tsx | 97 +++++++++++++------ .../components/SavedViewsToolbar.tsx | 82 ++++++++++++++++ .../features/embeds/mutations/createEmbed.ts | 45 ++++++--- studio/features/embeds/queries/getEmbed.ts | 3 +- studio/features/viewer/components/Viewer.tsx | 6 +- 9 files changed, 297 insertions(+), 48 deletions(-) create mode 100644 studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts create mode 100644 studio/features/editor-toolbar/components/SavedViewsToolbar.tsx diff --git a/studio/app/api/embeds/route.ts b/studio/app/api/embeds/route.ts index 61771cdd..15ffe084 100644 --- a/studio/app/api/embeds/route.ts +++ b/studio/app/api/embeds/route.ts @@ -8,24 +8,49 @@ const postSchema = zfd.formData({ projectId: zfd.numeric(), name: zfd.text(), onlyTooltipInfo: zfd.checkbox().optional(), + savedViewIds: z.array(z.coerce.number()).optional(), }); export async function POST(req: Request) { try { - const data = postSchema.parse(await req.formData()); + const formData = await req.formData(); + console.log("Received form data keys:", Array.from(formData.keys())); + console.log("savedViewIds values:", formData.getAll("savedViewIds")); + console.log("onlyTooltipInfo value:", formData.get("onlyTooltipInfo")); + + const data = postSchema.parse(formData); + console.log("Parsed data:", { + projectId: data.projectId, + name: data.name, + onlyTooltipInfo: data.onlyTooltipInfo, + savedViewIds: data.savedViewIds, + }); + const model = await createEmbed( data.projectId, data.name, data.dataFile, data.thumbnailFileContents, data.onlyTooltipInfo ?? false, + Array.isArray(data.savedViewIds) ? data.savedViewIds : [], ); return Response.json(model, { status: 201 }); } catch (e) { if (e instanceof z.ZodError) { - return new Response(e.message, { status: 400 }); + console.error("Validation error:", e.errors); + return new Response(JSON.stringify({ error: "Validation failed", details: e.errors }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } - throw e; + console.error("Other error:", e); + return new Response( + JSON.stringify({ error: "Internal server error", message: e instanceof Error ? e.message : "Unknown error" }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); } } diff --git a/studio/features/api-sdk/uploadEmbed.ts b/studio/features/api-sdk/uploadEmbed.ts index a93c1f8a..3641f7de 100644 --- a/studio/features/api-sdk/uploadEmbed.ts +++ b/studio/features/api-sdk/uploadEmbed.ts @@ -6,6 +6,7 @@ export default async function uploadEmbed( thumbnailFileContents: string, name: string, onlyTooltipInfo?: boolean, + savedViewIds?: number[], ) { const formData = new FormData(); @@ -15,7 +16,31 @@ export default async function uploadEmbed( formData.append("name", name); if (onlyTooltipInfo) formData.append("onlyTooltipInfo", "on"); - const response = await axios.post("/api/embeds", formData); + // Add saved view IDs to form data + if (savedViewIds && savedViewIds.length > 0) { + savedViewIds.forEach((id) => { + formData.append("savedViewIds", id.toString()); + }); + } - return response; + console.log("Uploading embed with data:", { + projectId, + name, + onlyTooltipInfo, + savedViewIds, + formDataKeys: Array.from(formData.keys()), + }); + + try { + const response = await axios.post("/api/embeds", formData); + console.log("Upload successful:", response.data); + return response; + } catch (error) { + console.error("Upload failed:", error); + if (axios.isAxiosError(error) && error.response) { + console.error("Response data:", error.response.data); + console.error("Response status:", error.response.status); + } + throw error; + } } diff --git a/studio/features/db/entities/embed.ts b/studio/features/db/entities/embed.ts index 0891739d..67c6c75d 100644 --- a/studio/features/db/entities/embed.ts +++ b/studio/features/db/entities/embed.ts @@ -2,11 +2,14 @@ import { Column, CreateDateColumn, Entity, + JoinTable, + ManyToMany, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { Project } from "./project"; +import { SavedView } from "./savedView"; @Entity("embeds") export class Embed { @@ -23,6 +26,20 @@ export class Embed { @Column({ default: false }) onlyTooltipInfo!: boolean; + @ManyToMany(() => SavedView) + @JoinTable({ + name: "embeds_saved_views_saved_views", + joinColumn: { + name: "embedsId", + referencedColumnName: "id", + }, + inverseJoinColumn: { + name: "savedViewsId", + referencedColumnName: "id", + }, + }) + savedViews?: SavedView[]; + @CreateDateColumn() createdAt!: Date; @UpdateDateColumn() updatedAt!: Date; } diff --git a/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts b/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts new file mode 100644 index 00000000..1ef9fdb0 --- /dev/null +++ b/studio/features/db/migrations/1750715800001-add-embed-saved-views-relationship.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddEmbedSavedViewsRelationship1750715800001 implements MigrationInterface { + name = "AddEmbedSavedViewsRelationship1750715800001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "embeds_saved_views_saved_views" ("embedsId" integer NOT NULL, "savedViewsId" integer NOT NULL, CONSTRAINT "PK_embeds_saved_views_relationship" PRIMARY KEY ("embedsId", "savedViewsId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_embeds_saved_views_embeds" ON "embeds_saved_views_saved_views" ("embedsId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_embeds_saved_views_saved_views" ON "embeds_saved_views_saved_views" ("savedViewsId")`, + ); + await queryRunner.query( + `ALTER TABLE "embeds_saved_views_saved_views" ADD CONSTRAINT "FK_embeds_saved_views_embeds" FOREIGN KEY ("embedsId") REFERENCES "embeds"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "embeds_saved_views_saved_views" ADD CONSTRAINT "FK_embeds_saved_views_saved_views" FOREIGN KEY ("savedViewsId") REFERENCES "saved_views"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "embeds_saved_views_saved_views" DROP CONSTRAINT "FK_embeds_saved_views_saved_views"`, + ); + await queryRunner.query( + `ALTER TABLE "embeds_saved_views_saved_views" DROP CONSTRAINT "FK_embeds_saved_views_embeds"`, + ); + await queryRunner.query(`DROP INDEX "IDX_embeds_saved_views_saved_views"`); + await queryRunner.query(`DROP INDEX "IDX_embeds_saved_views_embeds"`); + await queryRunner.query(`DROP TABLE "embeds_saved_views_saved_views"`); + } +} diff --git a/studio/features/editor-exports/components/EditorExportsCreate.tsx b/studio/features/editor-exports/components/EditorExportsCreate.tsx index 738af4a5..231061f5 100644 --- a/studio/features/editor-exports/components/EditorExportsCreate.tsx +++ b/studio/features/editor-exports/components/EditorExportsCreate.tsx @@ -23,28 +23,40 @@ import uploadEmbed from "@features/api-sdk/uploadEmbed"; import useMetadataContext from "@features/editor-metadata/hooks/useMetadataContext"; import useExportEmbed from "@features/editor/hooks/useExportModels"; import { useRenderer } from "@features/editor/hooks/useRender"; -import { useCallback, useState } from "react"; +import { useSavedViews } from "@features/saved-views/hooks/useSavedViews"; +import { useCallback, useEffect, useState } from "react"; type EditorExportsCreateProps = { sanitizedId: number; }; -export default function EditorExportsCreate({ - sanitizedId, -}: EditorExportsCreateProps) { +export default function EditorExportsCreate({ sanitizedId }: EditorExportsCreateProps) { const { columns } = useMetadataContext(); + const { savedViews, fetchSavedViews } = useSavedViews(sanitizedId); const [name, setName] = useState("Untitled Embed"); const [selectedKeys, setSelectedKeys] = useState([]); + const [selectedSavedViewKeys, setSelectedSavedViewKeys] = useState([]); const [isSavingDialogOpen, setIsSavingDialogOpen] = useState(false); const [onlyTooltipInfo, setOnlyTooltipInfo] = useState(false); const exportEmbeds = useExportEmbed(); const renderer = useRenderer(); + useEffect(() => { + fetchSavedViews(); + }, [fetchSavedViews]); + const saveEmbed = useCallback(() => { async function handleUploadEmbed(dataFile: File, thumbnailFileContents: string) { - await uploadEmbed(sanitizedId, dataFile, thumbnailFileContents, name, onlyTooltipInfo); + await uploadEmbed( + sanitizedId, + dataFile, + thumbnailFileContents, + name, + onlyTooltipInfo, + selectedSavedViewKeys.map((id) => parseInt(id)), + ); setIsSavingDialogOpen(false); } @@ -62,7 +74,7 @@ export default function EditorExportsCreate({ //upload project version void handleUploadEmbed(dataFile, image); }; - }, [sanitizedId, name, exportEmbeds, renderer, selectedKeys, onlyTooltipInfo]); + }, [sanitizedId, name, exportEmbeds, renderer, selectedKeys, selectedSavedViewKeys, onlyTooltipInfo]); const handleGlobalAction = useCallback( (key: Key) => { @@ -77,27 +89,62 @@ export default function EditorExportsCreate({ - + - Only include tooltip information + Only include tooltip information + + {/* Saved Views Selection */} + + + Choose saved views to include (optional) + + + + + { + if (keys === "all") { + setSelectedSavedViewKeys(savedViews.map((item) => item.id.toString())); + } else { + setSelectedSavedViewKeys(Array.from(keys) as string[]); + } + }} + > + {(item) => ( + + {item.name} + + )} + + + + + {/* Columns Selection */} - + { + if (!selectedKey) return; + + const selectedView = savedViews.find((view) => view.id.toString() === selectedKey.toString()); + + if (!selectedView) return; + + // Set camera position in the editor context + const currentView = renderer.views?.[activeView]; + if (!currentView) { + console.error("No active view found"); + return; + } + + // Set camera position and target to the saved view values + currentView.view.camera.set({ + position: selectedView.cameraPosition, + target: selectedView.cameraTarget, + projectionType: selectedView.projectionType, + fovYRadian: selectedView.fovYRadian, + }); + + // For orthographic projection, set the saved orthographic bounds + if (selectedView.projectionType === ProjectionType.ORTHOGRAPHIC) { + currentView.view.camera.setOrthographicBounds( + selectedView.orthographicLeft, + selectedView.orthographicRight, + selectedView.orthographicBottom, + selectedView.orthographicTop, + ); + } + + // Update the camera matrices to reflect the new position + currentView.view.camera.updateProjectionViewMatrix(); + + console.log("Loaded saved view:", selectedView.name); + }, + [renderer.views, activeView, savedViews], + ); + + // Only show in embed mode and if there are saved views + if (!embedMode || savedViews.length === 0) { + return null; + } + + return ( + + + + {(item) => ( + + {item.name} + + )} + + Select saved view + + + ); +} diff --git a/studio/features/embeds/mutations/createEmbed.ts b/studio/features/embeds/mutations/createEmbed.ts index f3ab17bf..af8417da 100644 --- a/studio/features/embeds/mutations/createEmbed.ts +++ b/studio/features/embeds/mutations/createEmbed.ts @@ -1,15 +1,13 @@ import { canEditProject } from "@features/auth/acl"; import { Embed } from "@features/db/entities/embed"; +import { SavedView } from "@features/db/entities/savedView"; import { injectRepository } from "@features/db/helpers"; import { toPlain } from "@features/helpers/objects"; -import { - ensureBucket, - getEmbedBucketName, - saveFileStream, -} from "@features/storage"; +import { ensureBucket, getEmbedBucketName, saveFileStream } from "@features/storage"; import { randomUUID } from "crypto"; import { Readable } from "stream"; import { ReadableStream } from "stream/web"; +import { In } from "typeorm"; export async function createEmbed( projectId: number, @@ -17,17 +15,26 @@ export async function createEmbed( file: File, thumbnailFileContents: string, onlyTooltipInfo: boolean = false, + savedViewIds: number[] = [], ) { if (!(await canEditProject())) throw new Error("Unauthorized"); const embedRepository = await injectRepository(Embed); + const savedViewRepository = await injectRepository(SavedView); const versionFileName = randomUUID(); - const bucketName = getEmbedBucketName(versionFileName); - // create the embed in the database - const embed = await embedRepository.save({ + // Get saved views if IDs are provided + let savedViews: SavedView[] = []; + if (savedViewIds.length > 0) { + savedViews = await savedViewRepository.find({ + where: { id: In(savedViewIds) }, + }); + } + + // create the embed in the database (without saved views for now) + const embed = embedRepository.create({ project: { id: projectId }, name: name, thumbnailContents: thumbnailFileContents, @@ -35,16 +42,32 @@ export async function createEmbed( onlyTooltipInfo: onlyTooltipInfo, }); + // Save the embed + const savedEmbed = await embedRepository.save(embed); + + // Use the relation API to set the many-to-many relationship + if (savedViews.length > 0) { + await embedRepository + .createQueryBuilder() + .relation(Embed, "savedViews") + .of(savedEmbed) + .add(savedViews.map((v) => v.id)); + } + // save the files to the bucket try { await ensureBucket(embed.bucketName); const fileStream = Readable.fromWeb(file.stream() as ReadableStream); await saveFileStream(file.name, embed.bucketName, fileStream); } catch (e) { - await embedRepository.remove(embed); + await embedRepository.remove(savedEmbed); throw e; } - // return the embed - return toPlain(embed); + // return the embed with saved views + const finalEmbed = await embedRepository.findOne({ + where: { id: savedEmbed.id }, + relations: ["savedViews"], + }); + return toPlain(finalEmbed); } diff --git a/studio/features/embeds/queries/getEmbed.ts b/studio/features/embeds/queries/getEmbed.ts index 2fc62812..b23551c3 100644 --- a/studio/features/embeds/queries/getEmbed.ts +++ b/studio/features/embeds/queries/getEmbed.ts @@ -1,7 +1,5 @@ "use server"; -import { canReadProjects } from "@features/auth/acl"; -import { getUserToken } from "@features/auth/user"; import { Embed } from "@features/db/entities/embed"; import { injectRepository } from "@features/db/helpers"; import { toPlain } from "@features/helpers/objects"; @@ -11,6 +9,7 @@ export default async function getEmbed(id: number) { const embed = await embedRepository.findOne({ where: { id }, + relations: ["savedViews"], }); if (!embed) throw new Error("Not found"); diff --git a/studio/features/viewer/components/Viewer.tsx b/studio/features/viewer/components/Viewer.tsx index a9c338fd..a9ba4c57 100644 --- a/studio/features/viewer/components/Viewer.tsx +++ b/studio/features/viewer/components/Viewer.tsx @@ -7,6 +7,7 @@ import ActiveColumnToolbar from "@features/editor-toolbar/components/ActiveColum import CameraViewToolbar from "@features/editor-toolbar/components/CameraViewToolbar"; import ColorSchemeToolbar from "@features/editor-toolbar/components/ColorSchemeToolbar"; import ProjectionToolbar from "@features/editor-toolbar/components/ProjectionToolbar"; +import SavedViewsToolbar from "@features/editor-toolbar/components/SavedViewsToolbar"; import ScreenshotToolbar from "@features/editor-toolbar/components/ScreenshotToolbar"; import SelectionToolbar from "@features/editor-toolbar/components/SelectionToolbar"; @@ -31,8 +32,8 @@ export default function Viewer(props: ViewerProps) { {!embed?.onlyTooltipInfo && } +