diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..f4578817 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,109 @@ +# Copilot Instructions — Weave.js Frontend Showcase + +## Repository Overview + +This is the **Weave.js Frontend Showcase**, a demo application for [Weave.js](https://github.com/InditexTech/weavejs) — a collaborative canvas framework backed by Konva.js and Yjs. All application code lives in the `/code` subdirectory. The README mentions Next.js, but the actual framework is **TanStack Start** (React + TanStack Router + Vite + Nitro). + +## Commands + +All commands are run from the `/code` directory: + +```bash +npm install # install dependencies +npm run dev # start dev server on https://localhost:3000 +npm run build # production build + TypeScript check (vite build + tsc --noEmit) +npm run lint # ESLint +npm run format # Prettier (formats api, app, assets, components, lib, store) +``` + +Vitest is configured (`vitest.config.mts`) but no tests exist yet. The test environment is jsdom. + +## Architecture + +### Framework & Routing + +- **TanStack Start** (Vite + Nitro) with **TanStack Router** file-based routing +- Routes live in `src/routes/` — `__root.tsx` is the layout root +- API route handlers are in `src/routes/api/` +- The Nitro server proxies all `/weavebff/**` requests to `VITE_BACKEND_ENDPOINT` (the Weave.js backend) + +### Weave.js Integration + +The core of the app is `` rendered in `components/room/room.tsx`. It receives: +- **renderer**: Konva-based renderer (from `useGetRendererKonvaBase`) +- **store**: Azure Web PubSub store for real-time collaboration (from `useGetAzureWebPubSubProvider`) +- **nodes/plugins/actions**: registered from `components/utils/weave/{nodes,plugins,actions}.ts` + +Custom extensions of the Weave.js SDK go in: +- `components/nodes/` — custom canvas node types (e.g. PantoneNode, ColorTokenNode) +- `components/plugins/` — custom canvas plugins +- `components/actions/` — custom canvas tools/actions + +### State Management + +Two separate state layers: +1. **Zustand** (`useCollaborationRoom` from `@/store/store`) — all UI/room state (sidebar visibility, selected tools, pages, configuration, etc.) +2. **TanStack Query** — server state (rooms list, templates, images, threads, etc.) +3. **`useWeave`** from `@inditextech/weave-react` — Weave.js instance state (canvas status, loaded room, etc.) + +### Canvas Room Lifecycle + +A "room" is a multi-page collaborative canvas workspace. The room route (`src/routes/rooms/$roomId.tsx`) is wrapped in `` and `` because Weave.js is browser-only. Room initialization flow: load params → fetch connection URL → connect Azure Web PubSub → load room data → render ``. + +### Authentication & Session + +- Auth uses `better-auth` (`lib/auth.client.ts`) +- Session is accessed via `useGetSession` hook +- The sign-in overlay (`components/sign-overlay/`) is rendered over rooms that require auth + +### API Layer + +Thin fetch-wrapper functions in `/code/api/` — one file per endpoint, following `{method}-{resource}.ts` naming (e.g. `get-rooms.ts`, `post-image.ts`, `del-template.ts`). + +### AI Features + +- AI chat panel (`components/room-components/ai-components/chatbot.*.tsx`) +- Vercel AI SDK (`ai`, `@ai-sdk/react`) with Google Gemini and OpenRouter providers +- Server-side AI route handlers in `src/routes/api/ai/` + +## Key Conventions + +### Path Aliases + +`@/*` maps to the `/code` root (e.g. `@/store/store`, `@/lib/utils`, `@/components/room/room`). + +### SPDX License Headers + +Every source file must begin with: +```ts +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 +``` + +### Styling + +- Tailwind CSS v4 (via `@tailwindcss/vite` plugin) +- shadcn/ui component library (`components/ui/`) using Radix UI primitives +- Use `cn()` from `@/lib/utils` (clsx + tailwind-merge) for conditional class names + +### Commits + +Conventional commits enforced by commitlint (`@commitlint/config-conventional`). Husky runs lint on pre-commit and build on pre-push. + +### Environment Variables + +Env vars are prefixed with `VITE_` (not `NEXT_PUBLIC_`). Copy `.env.example` to `.env` in `/code`: + +``` +VITE_APP_HOST=https://localhost:3000 +VITE_API_ENDPOINT_HUB_NAME=weavejs +VITE_API_ENDPOINT=/weavebff/api/v1 +VITE_BACKEND_ENDPOINT=http://localhost:8081 +``` + +Set `DEV_WEAVEJS_REPO_PATH` to a local Weave.js monorepo path to use local SDK sources instead of npm packages (handled in `vite.config.ts` aliases). + +### Dependency Overrides + +`package.json` overrides pin specific versions of `konva`, `react`, `react-dom`, `yjs`, and `@tanstack/start-plugin-core`. Do not change these without understanding the compatibility constraints with the Weave.js SDK. diff --git a/REUSE.toml b/REUSE.toml index 045ae030..58d8a48a 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -29,6 +29,8 @@ path = [ "code/justfile", "code/package*.json", "code/tsconfig.json", + "code/components/ui/**", + "code/components/ai-elements/**" ] SPDX-FileCopyrightText = "2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.)" SPDX-License-Identifier = "Apache-2.0" diff --git a/code/api/del-room-image-fallback.ts b/code/api/del-room-image-fallback.ts new file mode 100644 index 00000000..ebc229be --- /dev/null +++ b/code/api/del-room-image-fallback.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + + +export const delRoomImageFallback = async ( + userId: string, + clientId: string, + roomId: string, + pageId: string, + imageId: string, + relative = true, +) => { + const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; + const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; + const backendEndpoint = import.meta.env.VITE_BACKEND_ENDPOINT; + + const server = `${backendEndpoint}/api/v1`; + const endpoint = `${relative ? apiEndpoint : server}/${hubName}/rooms/${roomId}/pages/${pageId}/image-fallback`; + + const response = await fetch(endpoint, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "x-weave-user-id": userId, + "x-weave-client-id": clientId, + }, + body: JSON.stringify({ + imageId, + }), + }); + + if (!response.ok) { + throw new Error( + `Error deleting an element from the room image-fallback map: ${response.statusText}`, + ); + } + + const data = await response.json(); + + return data; +}; diff --git a/code/api/get-room-image-fallback.ts b/code/api/get-room-image-fallback.ts new file mode 100644 index 00000000..5df3b4ac --- /dev/null +++ b/code/api/get-room-image-fallback.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const getRoomImageFallback = async (roomId: string, pageId: string) => { + const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; + const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; + + const endpoint = `${apiEndpoint}/${hubName}/rooms/${roomId}/pages/${pageId}/image-fallback`; + const response = await fetch(endpoint); + + if (!response.ok && response.status === 404) { + return {}; + } + + const data = (await response.json()) as Record; + return data; +}; diff --git a/code/api/get-templates.ts b/code/api/get-templates.ts index 8ce0512a..73c62e9e 100644 --- a/code/api/get-templates.ts +++ b/code/api/get-templates.ts @@ -4,13 +4,15 @@ export const getTemplates = async ( roomId: string, + kind?: string, + imageSlots?: number, offset: number = 0, limit: number = 20, ) => { const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; - const endpoint = `${apiEndpoint}/${hubName}/rooms/${roomId}/templates?offset=${offset}&limit=${limit}`; + const endpoint = `${apiEndpoint}/${hubName}/rooms/${roomId}/templates?offset=${offset}&limit=${limit}${kind ? `&kind=${kind}` : ""}${imageSlots ? `&imageSlots=${imageSlots}` : ""}`; const response = await fetch(endpoint); const data = await response.json(); diff --git a/code/api/pages/post-page.ts b/code/api/pages/post-page.ts index ea847a0a..86b5aae3 100644 --- a/code/api/pages/post-page.ts +++ b/code/api/pages/post-page.ts @@ -9,7 +9,22 @@ export const postPage = async ( name, thumbnail, position, - }: { pageId: string; name: string; thumbnail: string; position?: number }, + templateId, + target, + }: { + pageId: string; + name: string; + thumbnail: string; + position?: number; + templateId?: string; + target?: { + id: string; + position: { + x: number; + y: number; + }; + }; + }, ) => { const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; @@ -26,11 +41,13 @@ export const postPage = async ( name, thumbnail, position, + templateId, + target, }), }); if (!response.ok) { - throw new Error(`Error creating chat: ${response.statusText}`); + throw new Error(`Error creating page: ${response.statusText}`); } const data = await response.json(); diff --git a/code/api/post-add-image-template-to-room.ts b/code/api/post-add-image-template-to-room.ts new file mode 100644 index 00000000..7b46b99e --- /dev/null +++ b/code/api/post-add-image-template-to-room.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export type PostAddImageTemplateToRoomPayload = { + roomId: string; + pageId: string; + templateId: string; + target: { + id: string; + position: { + x: number; + y: number; + }; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: Record; +}; + +export const postAddImageTemplateToRoom = async ( + payload: PostAddImageTemplateToRoomPayload, +) => { + const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; + const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; + + const endpoint = `${apiEndpoint}/${hubName}/templates/add-image-template-to-room`; + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: payload ? JSON.stringify(payload) : undefined, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error("Failed to add image template to room: " + errorData.error); + } + + const data = await response.json(); + + return data; +}; diff --git a/code/api/post-add-template-to-room.ts b/code/api/post-add-template-to-room.ts index 07f665f3..da1ffa22 100644 --- a/code/api/post-add-template-to-room.ts +++ b/code/api/post-add-template-to-room.ts @@ -3,12 +3,16 @@ // SPDX-License-Identifier: Apache-2.0 export type PostAddTemplateToRoomPayload = { - roomId?: string; - roomName?: string; - frameName: string; - templateInstanceId: string; + roomId: string; + pageId: string; templateId: string; - imagesIds: string[]; + target: { + id: string; + position: { + x: number; + y: number; + }; + }; }; export const postAddTemplateToRoom = async ( diff --git a/code/api/post-room-image-fallback.ts b/code/api/post-room-image-fallback.ts new file mode 100644 index 00000000..b746703f --- /dev/null +++ b/code/api/post-room-image-fallback.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) +// +// SPDX-License-Identifier: Apache-2.0 + +export const postRoomImageFallback = async ( + userId: string, + clientId: string, + roomId: string, + pageId: string, + imageId: string, + dataURL: string, + relative = true, +) => { + const apiEndpoint = import.meta.env.VITE_API_ENDPOINT; + const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; + const backendEndpoint = import.meta.env.VITE_BACKEND_ENDPOINT; + + const server = `${backendEndpoint}/api/v1`; + const endpoint = `${relative ? apiEndpoint : server}/${hubName}/rooms/${roomId}/pages/${pageId}/image-fallback`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-weave-user-id": userId, + "x-weave-client-id": clientId, + }, + body: JSON.stringify({ + imageId, + dataURL, + }), + }); + + if (!response.ok) { + throw new Error( + `Error adding an element to the room image-fallback map: ${response.statusText}`, + ); + } + + const data = await response.json(); + + return data; +}; diff --git a/code/api/post-template.ts b/code/api/post-template.ts index eb3d02cf..1d20d2ed 100644 --- a/code/api/post-template.ts +++ b/code/api/post-template.ts @@ -5,12 +5,16 @@ export const postTemplate = async ({ roomId, name, + kind, + imageSlots, linkedNodeType, templateImage, templateData, }: { roomId: string; name: string; + kind: string; + imageSlots?: number; linkedNodeType: string | null; templateImage: string; templateData: string; @@ -26,6 +30,8 @@ export const postTemplate = async ({ }, body: JSON.stringify({ name, + kind, + ...(imageSlots && { imageSlots }), linkedNodeType, templateImage, templateData, diff --git a/code/api/types.ts b/code/api/types.ts index 43f172c0..bd75a0bf 100644 --- a/code/api/types.ts +++ b/code/api/types.ts @@ -4,7 +4,7 @@ export type ImageModel = | "openai/gpt-image-1" - | "gemini/gemini-2.5-flash-image-preview"; + | "gemini-3.1-flash-image-preview"; export type ImageQuality = "low" | "medium" | "high"; export type ImageModeration = "low" | "auto"; export type ImageSampleCount = number; diff --git a/code/api/v2/post-image.ts b/code/api/v2/post-image.ts index 50594726..76723934 100644 --- a/code/api/v2/post-image.ts +++ b/code/api/v2/post-image.ts @@ -2,9 +2,14 @@ // // SPDX-License-Identifier: Apache-2.0 -export const postImage = async (roomId: string, file: File) => { +export const postImage = async ( + roomId: string, + imageId: string, + file: File, +) => { const formData = new FormData(); formData.append("file", file); + formData.append("imageId", imageId); const apiEndpoint = import.meta.env.VITE_API_V2_ENDPOINT; const hubName = import.meta.env.VITE_API_ENDPOINT_HUB_NAME; diff --git a/code/components/ai-elements/agent.tsx b/code/components/ai-elements/agent.tsx new file mode 100644 index 00000000..86dbcc0f --- /dev/null +++ b/code/components/ai-elements/agent.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Tool } from "ai"; +import { BotIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { memo } from "react"; + +import { CodeBlock } from "./code-block"; + +export type AgentProps = ComponentProps<"div">; + +export const Agent = memo(({ className, ...props }: AgentProps) => ( +
+)); + +export type AgentHeaderProps = ComponentProps<"div"> & { + name: string; + model?: string; +}; + +export const AgentHeader = memo( + ({ className, name, model, ...props }: AgentHeaderProps) => ( +
+
+ + {name} + {model && ( + + {model} + + )} +
+
+ ) +); + +export type AgentContentProps = ComponentProps<"div">; + +export const AgentContent = memo( + ({ className, ...props }: AgentContentProps) => ( +
+ ) +); + +export type AgentInstructionsProps = ComponentProps<"div"> & { + children: string; +}; + +export const AgentInstructions = memo( + ({ className, children, ...props }: AgentInstructionsProps) => ( +
+ + Instructions + +
+

{children}

+
+
+ ) +); + +export type AgentToolsProps = ComponentProps; + +export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => ( +
+ Tools + +
+)); + +export type AgentToolProps = ComponentProps & { + tool: Tool; +}; + +export const AgentTool = memo( + ({ className, tool, value, ...props }: AgentToolProps) => { + const schema = + "jsonSchema" in tool && tool.jsonSchema + ? tool.jsonSchema + : tool.inputSchema; + + return ( + + + {tool.description ?? "No description"} + + +
+ +
+
+
+ ); + } +); + +export type AgentOutputProps = ComponentProps<"div"> & { + schema: string; +}; + +export const AgentOutput = memo( + ({ className, schema, ...props }: AgentOutputProps) => ( +
+ + Output Schema + +
+ +
+
+ ) +); + +Agent.displayName = "Agent"; +AgentHeader.displayName = "AgentHeader"; +AgentContent.displayName = "AgentContent"; +AgentInstructions.displayName = "AgentInstructions"; +AgentTools.displayName = "AgentTools"; +AgentTool.displayName = "AgentTool"; +AgentOutput.displayName = "AgentOutput"; diff --git a/code/components/ai-elements/artifact.tsx b/code/components/ai-elements/artifact.tsx index e63a9859..94626e5b 100644 --- a/code/components/ai-elements/artifact.tsx +++ b/code/components/ai-elements/artifact.tsx @@ -1,6 +1,4 @@ -// SPDX-FileCopyrightText: 2025 2025 INDUSTRIA DE DISEÑO TEXTIL S.A. (INDITEX S.A.) -// -// SPDX-License-Identifier: Apache-2.0 +"use client"; import { Button } from "@/components/ui/button"; import { @@ -10,7 +8,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { type LucideIcon, XIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; import type { ComponentProps, HTMLAttributes } from "react"; export type ArtifactProps = HTMLAttributes; @@ -19,7 +18,7 @@ export const Artifact = ({ className, ...props }: ArtifactProps) => (
@@ -34,7 +33,7 @@ export const ArtifactHeader = ({
@@ -52,7 +51,7 @@ export const ArtifactClose = ({ + ); +}; + +// ============================================================================ +// AttachmentHoverCard - Hover preview +// ============================================================================ + +export type AttachmentHoverCardProps = ComponentProps; + +export const AttachmentHoverCard = ({ + openDelay = 0, + closeDelay = 0, + ...props +}: AttachmentHoverCardProps) => ( + +); + +export type AttachmentHoverCardTriggerProps = ComponentProps< + typeof HoverCardTrigger +>; + +export const AttachmentHoverCardTrigger = ( + props: AttachmentHoverCardTriggerProps, +) => ; + +export type AttachmentHoverCardContentProps = ComponentProps< + typeof HoverCardContent +>; + +export const AttachmentHoverCardContent = ({ + align = "start", + className, + ...props +}: AttachmentHoverCardContentProps) => ( + +); + +// ============================================================================ +// AttachmentEmpty - Empty state +// ============================================================================ + +export type AttachmentEmptyProps = HTMLAttributes; + +export const AttachmentEmpty = ({ + className, + children, + ...props +}: AttachmentEmptyProps) => ( +
+ {children ?? "No attachments"} +
+); diff --git a/code/components/ai-elements/audio-player.tsx b/code/components/ai-elements/audio-player.tsx new file mode 100644 index 00000000..78153eae --- /dev/null +++ b/code/components/ai-elements/audio-player.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "@/components/ui/button-group"; +import { cn } from "@/lib/utils"; +import type { Experimental_SpeechResult as SpeechResult } from "ai"; +import { + MediaControlBar, + MediaController, + MediaDurationDisplay, + MediaMuteButton, + MediaPlayButton, + MediaSeekBackwardButton, + MediaSeekForwardButton, + MediaTimeDisplay, + MediaTimeRange, + MediaVolumeRange, +} from "media-chrome/react"; +import type { ComponentProps, CSSProperties } from "react"; + +export type AudioPlayerProps = Omit< + ComponentProps, + "audio" +>; + +export const AudioPlayer = ({ + children, + style, + ...props +}: AudioPlayerProps) => ( + + {children} + +); + +export type AudioPlayerElementProps = Omit, "src"> & + ( + | { + data: SpeechResult["audio"]; + } + | { + src: string; + } + ); + +export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => ( + // oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer +