From c8a6cf0f790fcd9014d03f66f26290b6fea59037 Mon Sep 17 00:00:00 2001 From: Aleksandar Toplek Date: Fri, 8 Nov 2024 21:39:01 +0100 Subject: [PATCH] feat(uier): Connected toolbar to API and DB --- infra/apps/uier/Pulumi.next.yaml | 6 ++ infra/apps/uier/src/index.ts | 66 +++++++++++++- web/apps/uier/.env.example | 1 + web/apps/uier/app/(rest)/(marketing)/page.tsx | 10 --- web/apps/uier/app/api/comments/route.ts | 33 +++++++ web/apps/uier/next.config.js | 2 +- web/apps/uier/package.json | 2 + web/apps/uier/src/lib/db/client.ts | 26 ++++++ web/apps/uier/src/lib/db/schema.ts | 9 ++ web/apps/uier/src/lib/repo/commentsRepo.ts | 37 ++++++++ web/packages/uier-toolbar/package.json | 2 +- .../src/components/{ => @types}/Comments.tsx | 0 .../src/components/CommentBubble.tsx | 6 +- .../src/components/CommentPointOverlay.tsx | 2 +- .../components/CommentSelectionHighlight.tsx | 2 +- .../components/CommentSelectionPopover.tsx | 2 +- .../src/components/CommentThread.tsx | 4 +- .../src/components/CommentThreadItem.tsx | 52 ++--------- .../src/components/CommentToolbar.tsx | 5 +- .../src/components/CommentsBootstrapper.tsx | 2 +- .../src/components/CommentsGlobal.tsx | 22 ++--- .../src/components/info/DeviceInfo.tsx | 43 +++++++++ .../components/sidebar/CommentsSidebar.tsx | 36 +++----- .../sidebar/CommentsSidebarCommentsList.tsx | 29 ++++++ .../sidebar/CommentsSidebarHeader.tsx | 5 +- .../src/hooks/useCommentItemRects.tsx | 2 +- .../uier-toolbar/src/hooks/useComments.tsx | 88 +++++++++++++------ web/pnpm-lock.yaml | 6 ++ 28 files changed, 365 insertions(+), 135 deletions(-) create mode 100644 web/apps/uier/app/api/comments/route.ts create mode 100644 web/apps/uier/src/lib/db/client.ts create mode 100644 web/apps/uier/src/lib/db/schema.ts create mode 100644 web/apps/uier/src/lib/repo/commentsRepo.ts rename web/packages/uier-toolbar/src/components/{ => @types}/Comments.tsx (100%) create mode 100644 web/packages/uier-toolbar/src/components/info/DeviceInfo.tsx create mode 100644 web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarCommentsList.tsx diff --git a/infra/apps/uier/Pulumi.next.yaml b/infra/apps/uier/Pulumi.next.yaml index 8da0b9b45f..484539fa7c 100644 --- a/infra/apps/uier/Pulumi.next.yaml +++ b/infra/apps/uier/Pulumi.next.yaml @@ -1,4 +1,10 @@ config: + azure-native:clientId: f72781c2-20ca-47bf-bdfc-a39343fbaede + azure-native:clientSecret: + secure: AAABAKNv1KwWFJpsVdaGYA6F6CcjxQ/stOwVwh99ZQzIA3K/9WD9JOITtbIndAgOdipNfvlmmHASUOjuQsW6tosOXJKaVtgk + azure-native:location: westeurope + azure-native:subscriptionId: d22db5bd-ca9a-447c-9b1b-dc56aca401d7 + azure-native:tenantId: c7837eb3-77ca-413b-8653-c97b08886678 cloudflare:apiToken: secure: AAABAAqPzyAV9mvmavpxCxB+Ws+snM4JK8mNiAxa+WqmzniW5gNmJpHXGgXZI69SkL251Lhhrp43BUrvHnTEbAFf3lG8/Wsl uier:zoneid: diff --git a/infra/apps/uier/src/index.ts b/infra/apps/uier/src/index.ts index 78aa051f70..b112b322ef 100644 --- a/infra/apps/uier/src/index.ts +++ b/infra/apps/uier/src/index.ts @@ -1,7 +1,9 @@ import { nextJsApp } from '@infra/pulumi/vercel'; import { dnsRecord } from '@infra/pulumi/cloudflare'; -import { ProjectDomain } from '@pulumiverse/vercel'; +import { ProjectDomain, ProjectEnvironmentVariable } from '@pulumiverse/vercel'; import { getStack } from '@pulumi/pulumi'; +import { ResourceGroup } from '@pulumi/azure-native/resources/index.js'; +import { DatabaseAccount, SqlResourceSqlDatabase, SqlResourceSqlContainer, DatabaseAccountOfferType, listDatabaseAccountConnectionStringsOutput } from '@pulumi/azure-native/documentdb/index.js'; const up = async () => { const stack = getStack(); @@ -24,6 +26,68 @@ const up = async () => { dnsRecord('vercel-uier', '@', '76.76.21.21', 'A', false); } } + + // Azure + const resourceGroupName = `uier-${stack}`; + const resourceGroup = new ResourceGroup(resourceGroupName); + + // Create an Azure Cosmos DB Account for SQL API + const cosmosAccountName = 'uierdb'; + const databaseName = 'uierdata'; + const cosmosDbAccount = new DatabaseAccount(cosmosAccountName, { + resourceGroupName: resourceGroup.name, + databaseAccountOfferType: DatabaseAccountOfferType.Standard, + capabilities: [ + { + name: 'EnableServerless', // Use specific capabilities if needed + }, + ], + consistencyPolicy: { + defaultConsistencyLevel: 'Session', // Adjust the consistency level as needed + }, + locations: [ + { locationName: 'West Europe' }, + ], + }); + const cosmosPrimaryConnectionString = listDatabaseAccountConnectionStringsOutput({ + resourceGroupName: resourceGroup.name, + accountName: cosmosDbAccount.name, + }).apply(keys => keys.connectionStrings?.at(0)?.connectionString ?? ''); + + // Create a SQL Database within the Cosmos DB Account + const sqlDatabase = new SqlResourceSqlDatabase(databaseName, { + resourceGroupName: resourceGroup.name, + accountName: cosmosDbAccount.name, + resource: { + id: databaseName, + }, + }); + + // Creating the containers inside the database + const containerNames = ['comments']; + containerNames.map((containerName) => + new SqlResourceSqlContainer(`dbcontainer-${containerName}`, { + resourceGroupName: resourceGroup.name, + accountName: cosmosDbAccount.name, + databaseName: sqlDatabase.name, + containerName: containerName, + resource: { + id: containerName, + partitionKey: { + kind: 'Hash', + paths: ['/domain'], + }, + }, + }), + ); + + // Assign app environment variables + new ProjectEnvironmentVariable('vercel-uier-env-cosmos', { + projectId: app.projectId, + key: 'COSMOSDB_CONNECTION_STRING', + value: cosmosPrimaryConnectionString, + targets: stack === 'production' ? ['production'] : ['preview'], + }); }; export default up; \ No newline at end of file diff --git a/web/apps/uier/.env.example b/web/apps/uier/.env.example index e69de29bb2..024f2a56e3 100644 --- a/web/apps/uier/.env.example +++ b/web/apps/uier/.env.example @@ -0,0 +1 @@ +COSMOSDB_CONNECTION_STRING=secret diff --git a/web/apps/uier/app/(rest)/(marketing)/page.tsx b/web/apps/uier/app/(rest)/(marketing)/page.tsx index 2d01a4e4db..353a2cb517 100644 --- a/web/apps/uier/app/(rest)/(marketing)/page.tsx +++ b/web/apps/uier/app/(rest)/(marketing)/page.tsx @@ -20,16 +20,6 @@ const data: SectionData[] = [ header: 'Product', ctas: [ { label: 'uier', href: KnownPages.App }, - { label: 'uier', href: KnownPages.App }, - { label: 'uier', href: KnownPages.App }, - ] - }, - { - header: 'Product', - ctas: [ - { label: 'uier', href: KnownPages.App }, - { label: 'uier', href: KnownPages.App }, - { label: 'uier', href: KnownPages.App }, ] }, { diff --git a/web/apps/uier/app/api/comments/route.ts b/web/apps/uier/app/api/comments/route.ts new file mode 100644 index 0000000000..5c01734b25 --- /dev/null +++ b/web/apps/uier/app/api/comments/route.ts @@ -0,0 +1,33 @@ +import { createComment, getComments, updateComment } from '../../../src/lib/repo/commentsRepo'; + +export async function GET(request: Request) { + const domain = request.headers.get('host'); + if (!domain) + return new Response('Couldn\'t resolve domain', { status: 402 }); + + const comments = await getComments(domain); + return Response.json(comments); +} + +export async function POST(request: Request) { + const domain = request.headers.get('host'); + if (!domain) + return new Response('Couldn\'t resolve domain', { status: 402 }); + + const comment = await request.json() as { + id?: string, + path: string, + position: object, // TODO: Types + thread: object, // TODO: Types + device?: object, // TODO: Types + resolved?: boolean + }; + + if (comment.id) { + await updateComment(domain, comment.id, comment); + return Response.json({ id: comment.id }); + } else { + const id = await createComment({ domain, ...comment }); + return Response.json({ id }, { status: 201 }); + } +} diff --git a/web/apps/uier/next.config.js b/web/apps/uier/next.config.js index 5b3fe83206..3da2a475d1 100644 --- a/web/apps/uier/next.config.js +++ b/web/apps/uier/next.config.js @@ -38,7 +38,7 @@ const nextConfig = { [ knownSecureHeadersExternalUrls.vercel, knownSecureHeadersExternalUrls.googleFonts, - { scriptSrc: 'http://localhost:4005', styleSrc: 'http://localhost:4005' }, + { scriptSrc: 'http://localhost:4005', styleSrc: 'http://localhost:4005', connectSrc: 'http://localhost:4005' }, ] )) }]; diff --git a/web/apps/uier/package.json b/web/apps/uier/package.json index 783f7905f6..54d0c01907 100644 --- a/web/apps/uier/package.json +++ b/web/apps/uier/package.json @@ -17,6 +17,7 @@ "lint": "next lint" }, "dependencies": { + "@azure/cosmos": "4.1.1", "@enterwell/react-hooks": "0.5.0", "@next/env": "15.0.3", "@signalco/cms-components-marketing": "workspace:*", @@ -33,6 +34,7 @@ "@tanstack/react-query-devtools": "5.59.20", "@vercel/analytics": "1.3.2", "classix": "2.2.0", + "nanoid": "5.0.8", "next": "15.0.3", "next-secure-headers": "2.2.0", "next-themes": "0.4.3", diff --git a/web/apps/uier/src/lib/db/client.ts b/web/apps/uier/src/lib/db/client.ts new file mode 100644 index 0000000000..c9880006a3 --- /dev/null +++ b/web/apps/uier/src/lib/db/client.ts @@ -0,0 +1,26 @@ +import { type Container, CosmosClient, type Database } from '@azure/cosmos'; + +let client: CosmosClient | null = null; +let dataDb: Database | null = null; + +let commentsContainer: Container | null = null; + +function cosmosClient() { + if (client == null) { + const connectionString = process.env.COSMOSDB_CONNECTION_STRING; + if (!connectionString) + throw new Error('COSMOSDB_CONNECTION_STRING is not available in the environment. Please set it before using this feature.'); + + client = new CosmosClient(connectionString); + } + + return client; +} + +function cosmosDataDb() { + return dataDb = dataDb ?? cosmosClient().database('uierdata'); +} + +export function cosmosDataContainerComments() { + return commentsContainer = commentsContainer ?? cosmosDataDb().container('comments'); +} diff --git a/web/apps/uier/src/lib/db/schema.ts b/web/apps/uier/src/lib/db/schema.ts new file mode 100644 index 0000000000..096fcaa255 --- /dev/null +++ b/web/apps/uier/src/lib/db/schema.ts @@ -0,0 +1,9 @@ +export type DbComment = { + id: string; + domain: string; + path: string; + position: object, // TODO: Type + thread: object, // TODO: Type + device?: object, // TODO: Type + resolved?: boolean; +}; diff --git a/web/apps/uier/src/lib/repo/commentsRepo.ts b/web/apps/uier/src/lib/repo/commentsRepo.ts new file mode 100644 index 0000000000..33c3f526be --- /dev/null +++ b/web/apps/uier/src/lib/repo/commentsRepo.ts @@ -0,0 +1,37 @@ +import { nanoid } from 'nanoid'; +import { DbComment } from '../db/schema'; +import { cosmosDataContainerComments } from '../db/client'; + +export async function getComments(domain: string) { + return (await cosmosDataContainerComments().items.query({ + query: 'SELECT * FROM c WHERE c.domain = @domain', + parameters: [{ name: '@domain', value: domain }] + }).fetchAll()).resources; +} + +export async function createComment({ domain, path, position, thread, device }: { domain: string, path: string, position: object, thread: object, device?: object }) { + const container = cosmosDataContainerComments(); + const commentId = `comment_${nanoid()}`; + await container.items.create({ + id: commentId, + domain, + path, + position, // TODO: Sanitize + thread, // TODO: Sanitize + device, // TODO: Sanitize + }); + return commentId; +} + +export async function updateComment(domain: string, id: string, comment: { path: string, position: object, thread: object, device?: object, resolved?: boolean }) { + const container = cosmosDataContainerComments(); + await container.item(id, domain).replace({ + id, + domain, + ...comment + }); +} + +export async function deleteComment(domain: string, id: string) { + await cosmosDataContainerComments().item(id, domain).delete(); +} \ No newline at end of file diff --git a/web/packages/uier-toolbar/package.json b/web/packages/uier-toolbar/package.json index 5c4112d7bd..ebf94cd294 100644 --- a/web/packages/uier-toolbar/package.json +++ b/web/packages/uier-toolbar/package.json @@ -9,7 +9,7 @@ "./index.css": "./dist/index.css" }, "scripts": { - "dev:build": "tsup --watch --env.NODE_ENV development", + "dev:build": "tsup --watch ./src --env.NODE_ENV development", "dev:serve": "pnpm dlx serve dist -l 4005", "dev": "pnpm run /^dev:.*/", "build": "tsup --minify --env.NODE_ENV production", diff --git a/web/packages/uier-toolbar/src/components/Comments.tsx b/web/packages/uier-toolbar/src/components/@types/Comments.tsx similarity index 100% rename from web/packages/uier-toolbar/src/components/Comments.tsx rename to web/packages/uier-toolbar/src/components/@types/Comments.tsx diff --git a/web/packages/uier-toolbar/src/components/CommentBubble.tsx b/web/packages/uier-toolbar/src/components/CommentBubble.tsx index 14df876772..3f24ffbf79 100644 --- a/web/packages/uier-toolbar/src/components/CommentBubble.tsx +++ b/web/packages/uier-toolbar/src/components/CommentBubble.tsx @@ -15,8 +15,8 @@ import { useCommentItemRects } from '../hooks/useCommentItemRects'; import { CommentThread } from './CommentThread'; import { CommentSelectionHighlight } from './CommentSelectionHighlight'; import { CommentsBootstrapperContext } from './CommentsBootstrapperContext'; -import { CommentItem } from './Comments'; import { CommentIcon } from './CommentIcon'; +import { CommentItem } from './@types/Comments'; type CommentBubbleProps = HTMLAttributes & { defaultOpen?: boolean; @@ -45,7 +45,7 @@ export function CommentBubble({ e.preventDefault(); const formData = new FormData(e.currentTarget); e.currentTarget.reset(); - const commentId = await upsert.mutateAsync({ + const { id } = await upsert.mutateAsync({ ...commentItem, thread: { ...commentItem.thread, @@ -60,7 +60,7 @@ export function CommentBubble({ }); if (creating) { - onCreated?.(commentId); + onCreated?.(id); } }; diff --git a/web/packages/uier-toolbar/src/components/CommentPointOverlay.tsx b/web/packages/uier-toolbar/src/components/CommentPointOverlay.tsx index 067fb54088..5e14df35ad 100644 --- a/web/packages/uier-toolbar/src/components/CommentPointOverlay.tsx +++ b/web/packages/uier-toolbar/src/components/CommentPointOverlay.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react'; import { cx } from '@signalco/ui-primitives/cx'; import { getElementSelector } from '@signalco/js'; import { useWindowEvent } from '@signalco/hooks/useWindowEvent'; -import { CommentPoint } from './Comments'; +import { CommentPoint } from './@types/Comments'; type CommentPointOverlayProps = { onPoint: (commentPoint: CommentPoint) => void; diff --git a/web/packages/uier-toolbar/src/components/CommentSelectionHighlight.tsx b/web/packages/uier-toolbar/src/components/CommentSelectionHighlight.tsx index e2396a8b8f..91a87f2bfe 100644 --- a/web/packages/uier-toolbar/src/components/CommentSelectionHighlight.tsx +++ b/web/packages/uier-toolbar/src/components/CommentSelectionHighlight.tsx @@ -3,7 +3,7 @@ import { HTMLAttributes } from 'react'; import { cx } from '@signalco/ui-primitives/cx'; import { useCommentItemRects } from '../hooks/useCommentItemRects'; -import { CommentSelection } from './Comments'; +import { CommentSelection } from './@types/Comments'; type CommentSelectionHighlightProps = HTMLAttributes & { commentSelection: CommentSelection; diff --git a/web/packages/uier-toolbar/src/components/CommentSelectionPopover.tsx b/web/packages/uier-toolbar/src/components/CommentSelectionPopover.tsx index fbf1998adb..da52795fae 100644 --- a/web/packages/uier-toolbar/src/components/CommentSelectionPopover.tsx +++ b/web/packages/uier-toolbar/src/components/CommentSelectionPopover.tsx @@ -6,7 +6,7 @@ import { Button } from '@signalco/ui-primitives/Button'; import { Comment } from '@signalco/ui-icons'; import { orderBy } from '@signalco/js'; import { useResizeObserver } from '@enterwell/react-hooks'; -import { popoverWidth, popoverWindowMargin } from './Comments'; +import { popoverWidth, popoverWindowMargin } from './@types/Comments'; export function CommentSelectionPopover({ rects, onCreate }: { rects: DOMRect[]; onCreate: () => void; }) { const [popoverHeight, setPopoverHeight] = useState(0); diff --git a/web/packages/uier-toolbar/src/components/CommentThread.tsx b/web/packages/uier-toolbar/src/components/CommentThread.tsx index ff6c8400f8..ae275e5b56 100644 --- a/web/packages/uier-toolbar/src/components/CommentThread.tsx +++ b/web/packages/uier-toolbar/src/components/CommentThread.tsx @@ -3,9 +3,9 @@ import { Stack } from '@signalco/ui-primitives/Stack'; import { Divider } from '@signalco/ui-primitives/Divider'; import { Button } from '@signalco/ui-primitives/Button'; import { CommentThreadItem } from './CommentThreadItem'; -import { CommentItem } from './Comments'; +import { CommentItem } from './@types/Comments'; -export function CommentThread({ commentItem, onResolve }: { commentItem: CommentItem; onResolve?: () => void; }) { +export function CommentThread({ commentItem, onResolve }: { commentItem: CommentItem; onResolve: () => void; }) { const [expanded, setExpanded] = useState(false); if (!commentItem.thread.items.length) return null; diff --git a/web/packages/uier-toolbar/src/components/CommentThreadItem.tsx b/web/packages/uier-toolbar/src/components/CommentThreadItem.tsx index 9d7ee9fcb6..8b9e9b464d 100644 --- a/web/packages/uier-toolbar/src/components/CommentThreadItem.tsx +++ b/web/packages/uier-toolbar/src/components/CommentThreadItem.tsx @@ -1,51 +1,15 @@ 'use client'; -import { useContext, useState } from 'react'; import { Typography } from '@signalco/ui-primitives/Typography'; import { Stack } from '@signalco/ui-primitives/Stack'; import { Row } from '@signalco/ui-primitives/Row'; -import { Popper } from '@signalco/ui-primitives/Popper'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@signalco/ui-primitives/Menu'; import { IconButton } from '@signalco/ui-primitives/IconButton'; import { Avatar } from '@signalco/ui-primitives/Avatar'; -import { Check, Desktop, Mobile, MoreHorizontal, Tablet } from '@signalco/ui-icons'; +import { Check, MoreHorizontal } from '@signalco/ui-icons'; import { Timeago } from '@signalco/ui/Timeago'; -import { camelToSentenceCase } from '@signalco/js'; -import { CommentsBootstrapperContext } from './CommentsBootstrapperContext'; -import { CommentItem, CommentItemThreadItem } from './Comments'; - -function DeviceInfo({ device }: { device?: CommentItem['device'] }) { - const { rootElement } = useContext(CommentsBootstrapperContext); - const [open, setOpen] = useState(false); - const deviceSize = device?.size; - const DeviceSizeIcon = deviceSize === 'desktop' - ? Desktop - : (deviceSize === 'tablet' - ? Tablet - : (deviceSize === 'mobile' - ? Mobile - : null)); - - if (!DeviceSizeIcon) return null; - - return ( - setOpen(false)} - container={rootElement} - anchor={( - setOpen(true)} /> - )}> - - {device?.browser && {device.browser}} - {device?.os && {device.os}} - {deviceSize && {camelToSentenceCase(deviceSize)}} - {device?.windowSize && {device.windowSize[0]}x{device.windowSize[1]}} - {device?.pixelRatio && (x{device.pixelRatio})} - - - ); -} +import { DeviceInfo } from './info/DeviceInfo'; +import { CommentItem, CommentItemThreadItem } from './@types/Comments'; export function CommentThreadItem({ comment, threadItem, first, onDone }: { comment: CommentItem, threadItem: CommentItemThreadItem; first?: boolean; onDone?: () => void; }) { const { text } = threadItem; @@ -59,12 +23,14 @@ export function CommentThreadItem({ comment, threadItem, first, onDone }: { comm {avatarFallback} {author} - {first && ( + {/* TODO Enable when we fix device info UI */} + {/* {first && ( - )} - + )} */} + {/* TODO Enable when we have this info */} + {/* - + */} {first && ( diff --git a/web/packages/uier-toolbar/src/components/CommentToolbar.tsx b/web/packages/uier-toolbar/src/components/CommentToolbar.tsx index c89807490a..0aa1e5f851 100644 --- a/web/packages/uier-toolbar/src/components/CommentToolbar.tsx +++ b/web/packages/uier-toolbar/src/components/CommentToolbar.tsx @@ -31,14 +31,15 @@ export function CommentToolbar({ creatingPointComment, onAddPointComment, onShow title="Inbox"> - + {/* TODO Enable when implemented */} + {/* - + */} ); diff --git a/web/packages/uier-toolbar/src/components/CommentsBootstrapper.tsx b/web/packages/uier-toolbar/src/components/CommentsBootstrapper.tsx index 896ad509e1..cf21e8cd1a 100644 --- a/web/packages/uier-toolbar/src/components/CommentsBootstrapper.tsx +++ b/web/packages/uier-toolbar/src/components/CommentsBootstrapper.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { CommentsGlobal } from './CommentsGlobal'; import { CommentsBootstrapperContext } from './CommentsBootstrapperContext'; -import { CommentsGlobalProps } from './Comments'; +import { CommentsGlobalProps } from './@types/Comments'; const queryClient = new QueryClient(); diff --git a/web/packages/uier-toolbar/src/components/CommentsGlobal.tsx b/web/packages/uier-toolbar/src/components/CommentsGlobal.tsx index f09eef2cb6..5fc8d8696f 100644 --- a/web/packages/uier-toolbar/src/components/CommentsGlobal.tsx +++ b/web/packages/uier-toolbar/src/components/CommentsGlobal.tsx @@ -8,9 +8,9 @@ import { CommentsSidebar } from './sidebar/CommentsSidebar'; import { CommentToolbar } from './CommentToolbar'; import { CommentSelectionPopover } from './CommentSelectionPopover'; import { CommentSelectionHighlight } from './CommentSelectionHighlight'; -import { CommentsGlobalProps, CommentSelection, CommentPoint, CommentItem } from './Comments'; import { CommentPointOverlay } from './CommentPointOverlay'; import { CommentBubble } from './CommentBubble'; +import { CommentsGlobalProps, CommentSelection, CommentPoint, CommentItem } from './@types/Comments'; export function CommentsGlobal({ reviewParamKey = 'review', @@ -96,15 +96,17 @@ export function CommentsGlobal({ return ( <> - {(creatingComment ? [...(commentItems.data ?? []), creatingComment] : (commentItems.data ?? [])).map((commentItem) => ( - setCreatingComment(undefined)} - onCanceled={() => setCreatingComment(undefined)} - /> - ))} + {(creatingComment ? [...(commentItems.data ?? []), creatingComment] : (commentItems.data ?? [])).map((commentItem) => { + console.log('creating comment id', commentItem.id) + return ( + setCreatingComment(undefined)} + onCanceled={() => setCreatingComment(undefined)} /> + ); + })} {creatingCommentSelection && ( )} diff --git a/web/packages/uier-toolbar/src/components/info/DeviceInfo.tsx b/web/packages/uier-toolbar/src/components/info/DeviceInfo.tsx new file mode 100644 index 0000000000..0ad3f1edae --- /dev/null +++ b/web/packages/uier-toolbar/src/components/info/DeviceInfo.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useContext, useState } from 'react'; +import { Typography } from '@signalco/ui-primitives/Typography'; +import { Stack } from '@signalco/ui-primitives/Stack'; +import { Popper } from '@signalco/ui-primitives/Popper'; +import { Desktop, Mobile, Tablet } from '@signalco/ui-icons'; +import { camelToSentenceCase } from '@signalco/js'; +import { CommentsBootstrapperContext } from '../CommentsBootstrapperContext'; +import { type CommentItem } from '../@types/Comments'; + +export function DeviceInfo({ device }: { device?: CommentItem['device'] }) { + const { rootElement } = useContext(CommentsBootstrapperContext); + const [open, setOpen] = useState(false); + const deviceSize = device?.size; + const DeviceSizeIcon = deviceSize === 'desktop' + ? Desktop + : (deviceSize === 'tablet' + ? Tablet + : (deviceSize === 'mobile' + ? Mobile + : null)); + + if (!DeviceSizeIcon) return null; + + return ( + setOpen(false)} + container={rootElement} + anchor={( + setOpen(true)} /> + )}> + + {device?.browser && {device.browser}} + {device?.os && {device.os}} + {deviceSize && {camelToSentenceCase(deviceSize)}} + {device?.windowSize && {device.windowSize[0]}x{device.windowSize[1]}} + {device?.pixelRatio && (x{device.pixelRatio})} + + + ); +} \ No newline at end of file diff --git a/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebar.tsx b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebar.tsx index f3b9002281..46a7345241 100644 --- a/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebar.tsx +++ b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebar.tsx @@ -3,10 +3,9 @@ import { Stack } from '@signalco/ui-primitives/Stack'; import { Divider } from '@signalco/ui-primitives/Divider'; import { cx } from '@signalco/ui-primitives/cx'; import { Collapse } from '@signalco/ui-primitives/Collapse'; -import { CommentThread } from '../CommentThread'; -import { useComments } from '../../hooks/useComments'; import { CommentsSidebarHeader } from './CommentsSidebarHeader'; import { CommentsSidebarFilter } from './CommentsSidebarFilter'; +import { CommentsSidebarCommentsList } from './CommentsSidebarCommentsList'; import { CommentsFilter } from './CommentsFilter'; export type CommentsSidebarProps = { @@ -14,21 +13,6 @@ export type CommentsSidebarProps = { rootElement?: HTMLElement; }; -function SidebarCommentsList() { - const { query } = useComments(); - const { data: comments } = query; - - return ( - - {comments?.map((comment) => ( -
- -
- ))} -
- ); -} - export function CommentsSidebar({ onClose, rootElement }: CommentsSidebarProps) { const [filterOpen, setFilterOpen] = useState(false); const [filter, setFilter] = useState(); @@ -43,7 +27,7 @@ export function CommentsSidebar({ onClose, rootElement }: CommentsSidebarProps) return (
@@ -55,17 +39,17 @@ export function CommentsSidebar({ onClose, rootElement }: CommentsSidebarProps) onClose={handleClose} />
- -
- -
+ +
+ +
- +
diff --git a/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarCommentsList.tsx b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarCommentsList.tsx new file mode 100644 index 0000000000..604ee0a91c --- /dev/null +++ b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarCommentsList.tsx @@ -0,0 +1,29 @@ +import { Stack } from '@signalco/ui-primitives/Stack'; +import { CommentThread } from '../CommentThread'; +import { CommentItem } from '../@types/Comments'; +import { useComments } from '../../hooks/useComments'; + +export function CommentsSidebarCommentsList() { + const { query, upsert } = useComments(); + const { data: comments } = query; + + const handleResolveComment = async (commentItem: CommentItem) => { + await upsert.mutateAsync({ + ...commentItem, + resolved: true + }); + }; + + return ( + + {!comments?.length && ( +
No comments
+ )} + {comments?.map((commentItem) => ( +
+ handleResolveComment(commentItem)} /> +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarHeader.tsx b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarHeader.tsx index c6c105cb07..deec6fa40c 100644 --- a/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarHeader.tsx +++ b/web/packages/uier-toolbar/src/components/sidebar/CommentsSidebarHeader.tsx @@ -8,9 +8,10 @@ export function CommentsSidebarHeader({ filterOpen, onClose, onToggleFilter }: { - + {/* TODO Enable when implemented */} + {/* - + */} diff --git a/web/packages/uier-toolbar/src/hooks/useCommentItemRects.tsx b/web/packages/uier-toolbar/src/hooks/useCommentItemRects.tsx index 3c3cc5d724..198965a6af 100644 --- a/web/packages/uier-toolbar/src/hooks/useCommentItemRects.tsx +++ b/web/packages/uier-toolbar/src/hooks/useCommentItemRects.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { arrayMax, orderBy } from '@signalco/js'; import { useWindowRect } from '@signalco/hooks/useWindowRect'; -import { CommentItemPosition } from '../components/Comments'; +import { CommentItemPosition } from '../components/@types/Comments'; export function useCommentItemRects(commentSelection: CommentItemPosition | null | undefined) { diff --git a/web/packages/uier-toolbar/src/hooks/useComments.tsx b/web/packages/uier-toolbar/src/hooks/useComments.tsx index 56867d6116..4d2010f6a5 100644 --- a/web/packages/uier-toolbar/src/hooks/useComments.tsx +++ b/web/packages/uier-toolbar/src/hooks/useComments.tsx @@ -1,51 +1,81 @@ -'use client'; - import { UseMutationResult, UseQueryResult, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { CommentItem } from '../components/Comments'; +import { CommentItem } from '../components/@types/Comments'; + +// TODO: Call API +async function getComments() { + const response = await fetch('https://uier.io/api/comments'); + const comments = await response.json() as CommentItem[]; + return comments.filter(c => !c.resolved); +} + +// TODO: Call API +async function upsertComment(comment: CommentItem) { + const response = await fetch('http://uier.io/api/comments', { + method: 'POST', + body: JSON.stringify({ + id: comment.id, + domain: window.location.host, + path: window.location.pathname + window.location.search, // TODO: Exclude review param from search + position: comment.position, + thread: comment.thread, + device: comment.device, + resolved: comment.resolved + }) + }); + const { id } = await response.json(); + if (!id) { + throw new Error('Failed to create comment'); + } + + return id; +} + +// TODO: Call API +async function deleteComment(id: string) { + // const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; + // const currentComment = comments.find((c: CommentItem) => c.id === id); + // if (currentComment) { + // comments.splice(comments.indexOf(currentComment), 1); + // } + // localStorage.setItem('comments', JSON.stringify(comments)); + throw new Error('Not implemented'); +} export function useComments(): { query: UseQueryResult; - upsert: UseMutationResult; + upsert: UseMutationResult<{ id: string, isNew: boolean }, Error, CommentItem, unknown>; delete: UseMutationResult; } { const client = useQueryClient(); const query = useQuery({ queryKey: ['comments'], - queryFn: () => { - const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; - return comments.filter((c: CommentItem) => !c.resolved); - } + queryFn: getComments }); const mutateUpsert = useMutation({ mutationFn: async (comment: CommentItem) => { - const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; - if (!comment.id) { - comment.id = 'mock' + Math.random() + Date.now(); - } - const currentComment = comments.find((c: CommentItem) => Boolean(comment.id) && c.id === comment.id); - if (currentComment) { - Object.assign(currentComment, comment); + const id = await upsertComment(comment); + console.log('mutated', id, comment.id, comment.id !== id) + return ({ + id, + isNew: comment.id !== id + }); + }, + onSuccess: (id) => { + if (id) { + client.invalidateQueries({ + queryKey: ['comments'] + }); } else { - comments.push(comment); + client.invalidateQueries({ + queryKey: ['comments', id] + }); } - localStorage.setItem('comments', JSON.stringify(comments)); - client.invalidateQueries({ - queryKey: ['comments'] - }); - return comment.id; } }); const mutateDelete = useMutation({ - mutationFn: async (id: string) => { - const comments = JSON.parse(localStorage.getItem('comments') ?? '[]') as CommentItem[]; - const currentComment = comments.find((c: CommentItem) => c.id === id); - if (currentComment) { - comments.splice(comments.indexOf(currentComment), 1); - } - localStorage.setItem('comments', JSON.stringify(comments)); - }, + mutationFn: deleteComment, onSuccess: () => { client.invalidateQueries({ queryKey: ['comments'] diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e7faf45998..caa05af4d6 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -840,6 +840,9 @@ importers: apps/uier: dependencies: + '@azure/cosmos': + specifier: 4.1.1 + version: 4.1.1 '@enterwell/react-hooks': specifier: 0.5.0 version: 0.5.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020) @@ -888,6 +891,9 @@ importers: classix: specifier: 2.2.0 version: 2.2.0 + nanoid: + specifier: 5.0.8 + version: 5.0.8 next: specifier: 15.0.3 version: 15.0.3(@playwright/test@1.48.2)(babel-plugin-react-compiler@19.0.0-beta-8a03594-20241020)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.80.6)