From 1f094c4c5300e8bd2996e809a944bb3191a5d10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 4 Apr 2026 22:02:26 +0100 Subject: [PATCH] chore: remove files importing pruned packages (ai, cms, cognitive-context) Step 3 pruned packages/{ai,cms,cognitive-context} but left whole route groups + feature modules that depended on them. Those files were unbuildable since that prune. Removes them now so the workspace can be validated: Route groups: - apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/ - apps/web/src/app/[locale]/(marketing)/blog/ Feature modules: - apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/ - packages/api/src/modules/ai/ (chat, image, pdf, stt, tts, router) 3 stragglers remain (separate handoff to claudemesh-2): - apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx (cms) - apps/web/src/app/sitemap.ts (cms) - apps/web/src/modules/common/layout/credits/index.tsx (ai) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/[locale]/(apps)/chat/[id]/page.tsx | 63 -- .../src/app/[locale]/(apps)/chat/layout.tsx | 30 - .../web/src/app/[locale]/(apps)/chat/page.tsx | 23 - .../app/[locale]/(apps)/image/[id]/page.tsx | 77 --- .../[locale]/(apps)/image/history/page.tsx | 23 - .../src/app/[locale]/(apps)/image/layout.tsx | 18 - .../src/app/[locale]/(apps)/image/page.tsx | 40 -- .../app/[locale]/(apps)/pdf/[id]/layout.tsx | 12 - .../src/app/[locale]/(apps)/pdf/[id]/page.tsx | 57 -- .../src/app/[locale]/(apps)/pdf/layout.tsx | 14 - apps/web/src/app/[locale]/(apps)/pdf/page.tsx | 22 - .../src/app/[locale]/(apps)/tts/layout.tsx | 27 - apps/web/src/app/[locale]/(apps)/tts/page.tsx | 37 -- .../[locale]/(marketing)/blog/[slug]/page.tsx | 121 ---- .../app/[locale]/(marketing)/blog/page.tsx | 128 ---- .../chat/composer/components/voice-button.tsx | 125 ---- .../src/modules/chat/composer/dropzone.tsx | 56 -- .../chat/composer/hooks/use-attachments.tsx | 148 ----- .../chat/composer/hooks/use-composer.tsx | 209 ------- .../composer/hooks/use-voice-recording.tsx | 261 --------- apps/web/src/modules/chat/composer/index.tsx | 185 ------ apps/web/src/modules/chat/composer/types.ts | 38 -- apps/web/src/modules/chat/history/actions.tsx | 25 - apps/web/src/modules/chat/history/index.tsx | 97 --- .../src/modules/chat/history/list/index.tsx | 57 -- .../src/modules/chat/history/list/item.tsx | 167 ------ apps/web/src/modules/chat/layout/examples.tsx | 78 --- apps/web/src/modules/chat/layout/headline.tsx | 14 - apps/web/src/modules/chat/layout/new.tsx | 32 - apps/web/src/modules/chat/layout/view.tsx | 34 -- apps/web/src/modules/chat/lib/api.ts | 38 -- apps/web/src/modules/chat/thread/index.tsx | 39 -- .../chat/thread/message/assistant/index.tsx | 67 --- .../thread/message/assistant/reasoning.tsx | 86 --- .../assistant/tools/web-search/images.tsx | 110 ---- .../assistant/tools/web-search/index.tsx | 187 ------ .../assistant/tools/web-search/loading.tsx | 148 ----- .../chat/thread/message/user/index.tsx | 85 --- .../common/ai/composer/attachments.tsx | 227 -------- .../src/modules/common/ai/composer/index.tsx | 90 --- .../common/ai/composer/model-selector.tsx | 68 --- apps/web/src/modules/common/ai/icons.tsx | 16 - .../common/ai/thread/analyzing-image.tsx | 149 ----- .../common/ai/thread/controls/copy.tsx | 77 --- .../common/ai/thread/controls/index.tsx | 25 - .../common/ai/thread/controls/likes.tsx | 71 --- .../ai/thread/hooks/use-thread-layout.tsx | 102 ---- .../src/modules/common/ai/thread/index.tsx | 134 ----- .../src/modules/common/ai/thread/message.tsx | 66 --- .../image/composer/aspect-selector.tsx | 82 --- .../image/composer/image-count-selector.tsx | 67 --- apps/web/src/modules/image/composer/index.tsx | 103 ---- .../modules/image/composer/use-composer.tsx | 121 ---- apps/web/src/modules/image/generation/new.tsx | 38 -- .../modules/image/generation/view/details.tsx | 75 --- .../modules/image/generation/view/images.tsx | 317 ---------- .../modules/image/generation/view/index.tsx | 117 ---- apps/web/src/modules/image/history/cta.tsx | 44 -- apps/web/src/modules/image/history/index.tsx | 127 ---- .../modules/image/layout/background-grid.tsx | 83 --- .../web/src/modules/image/layout/examples.tsx | 70 --- .../web/src/modules/image/layout/headline.tsx | 14 - apps/web/src/modules/image/lib/api.ts | 42 -- .../modules/image/use-image-generation.tsx | 221 ------- .../modules/marketing/blog/tags-picker.tsx | 36 -- .../pdf/components/citation-preview.tsx | 140 ----- .../src/modules/pdf/components/citation.tsx | 86 --- apps/web/src/modules/pdf/components/index.ts | 8 - .../pdf/components/navigation-controls.tsx | 37 -- .../modules/pdf/components/recent-chats.tsx | 136 ----- .../pdf/components/text-selection-action.tsx | 126 ---- apps/web/src/modules/pdf/composer/index.tsx | 82 --- .../src/modules/pdf/composer/use-composer.tsx | 90 --- apps/web/src/modules/pdf/context/index.ts | 6 - .../pdf/context/pdf-viewer-context.tsx | 242 -------- apps/web/src/modules/pdf/history/actions.tsx | 25 - apps/web/src/modules/pdf/history/index.tsx | 96 --- .../src/modules/pdf/history/list/index.tsx | 57 -- .../web/src/modules/pdf/history/list/item.tsx | 166 ------ apps/web/src/modules/pdf/hooks/index.ts | 7 - .../modules/pdf/hooks/use-citation-unit.ts | 73 --- .../src/modules/pdf/hooks/use-embedding.ts | 48 -- .../modules/pdf/hooks/use-pdf-navigation.ts | 26 - apps/web/src/modules/pdf/index.ts | 29 - apps/web/src/modules/pdf/layout/layout.tsx | 209 ------- .../pdf/layout/preview/document-menu.tsx | 81 --- .../pdf/layout/preview/highlight-layer.tsx | 551 ------------------ .../src/modules/pdf/layout/preview/index.tsx | 93 --- .../pdf/layout/preview/page-navigation.tsx | 80 --- .../modules/pdf/layout/preview/pdf-viewer.css | 73 --- .../layout/preview/text-highlight-layer.tsx | 207 ------- .../modules/pdf/layout/preview/zoom-menu.tsx | 74 --- apps/web/src/modules/pdf/lib/api.ts | 70 --- apps/web/src/modules/pdf/thread/assistant.tsx | 90 --- .../modules/pdf/thread/citation-markdown.tsx | 247 -------- .../pdf/thread/copy-with-citations.tsx | 129 ---- .../pdf/thread/follow-up-suggestions.tsx | 69 --- apps/web/src/modules/pdf/thread/index.tsx | 112 ---- .../pdf/thread/suggested-questions.tsx | 77 --- apps/web/src/modules/pdf/thread/user.tsx | 20 - apps/web/src/modules/pdf/upload/confirm.tsx | 207 ------- .../modules/pdf/upload/hooks/use-upload.tsx | 44 -- apps/web/src/modules/pdf/upload/index.tsx | 155 ----- apps/web/src/modules/pdf/upload/url-form.tsx | 88 --- apps/web/src/modules/pdf/upload/utils.ts | 37 -- .../modules/tts/composer/hooks/use-audio.tsx | 66 --- .../tts/composer/hooks/use-composer.tsx | 137 ----- apps/web/src/modules/tts/composer/index.tsx | 104 ---- .../web/src/modules/tts/composer/settings.tsx | 243 -------- .../modules/tts/composer/voice-selector.tsx | 248 -------- apps/web/src/modules/tts/index.tsx | 48 -- apps/web/src/modules/tts/speech/avatar.tsx | 198 ------- apps/web/src/modules/tts/speech/index.tsx | 60 -- .../modules/tts/speech/voice-visualizer.tsx | 61 -- apps/web/src/modules/tts/use-tts.tsx | 104 ---- apps/web/src/modules/tts/utils/types.ts | 8 - packages/api/src/modules/ai/chat.ts | 48 -- packages/api/src/modules/ai/image.ts | 89 --- packages/api/src/modules/ai/pdf.ts | 254 -------- packages/api/src/modules/ai/router.ts | 20 - packages/api/src/modules/ai/stt.ts | 55 -- packages/api/src/modules/ai/tts.ts | 40 -- 122 files changed, 11536 deletions(-) delete mode 100644 apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/chat/layout.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/chat/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/image/history/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/image/layout.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/image/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/pdf/layout.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/pdf/page.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/tts/layout.tsx delete mode 100644 apps/web/src/app/[locale]/(apps)/tts/page.tsx delete mode 100644 apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx delete mode 100644 apps/web/src/app/[locale]/(marketing)/blog/page.tsx delete mode 100644 apps/web/src/modules/chat/composer/components/voice-button.tsx delete mode 100644 apps/web/src/modules/chat/composer/dropzone.tsx delete mode 100644 apps/web/src/modules/chat/composer/hooks/use-attachments.tsx delete mode 100644 apps/web/src/modules/chat/composer/hooks/use-composer.tsx delete mode 100644 apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx delete mode 100644 apps/web/src/modules/chat/composer/index.tsx delete mode 100644 apps/web/src/modules/chat/composer/types.ts delete mode 100644 apps/web/src/modules/chat/history/actions.tsx delete mode 100644 apps/web/src/modules/chat/history/index.tsx delete mode 100644 apps/web/src/modules/chat/history/list/index.tsx delete mode 100644 apps/web/src/modules/chat/history/list/item.tsx delete mode 100644 apps/web/src/modules/chat/layout/examples.tsx delete mode 100644 apps/web/src/modules/chat/layout/headline.tsx delete mode 100644 apps/web/src/modules/chat/layout/new.tsx delete mode 100644 apps/web/src/modules/chat/layout/view.tsx delete mode 100644 apps/web/src/modules/chat/lib/api.ts delete mode 100644 apps/web/src/modules/chat/thread/index.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/assistant/index.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/assistant/reasoning.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/assistant/tools/web-search/images.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/assistant/tools/web-search/index.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/assistant/tools/web-search/loading.tsx delete mode 100644 apps/web/src/modules/chat/thread/message/user/index.tsx delete mode 100644 apps/web/src/modules/common/ai/composer/attachments.tsx delete mode 100644 apps/web/src/modules/common/ai/composer/index.tsx delete mode 100644 apps/web/src/modules/common/ai/composer/model-selector.tsx delete mode 100644 apps/web/src/modules/common/ai/icons.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/analyzing-image.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/controls/copy.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/controls/index.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/controls/likes.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/hooks/use-thread-layout.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/index.tsx delete mode 100644 apps/web/src/modules/common/ai/thread/message.tsx delete mode 100644 apps/web/src/modules/image/composer/aspect-selector.tsx delete mode 100644 apps/web/src/modules/image/composer/image-count-selector.tsx delete mode 100644 apps/web/src/modules/image/composer/index.tsx delete mode 100644 apps/web/src/modules/image/composer/use-composer.tsx delete mode 100644 apps/web/src/modules/image/generation/new.tsx delete mode 100644 apps/web/src/modules/image/generation/view/details.tsx delete mode 100644 apps/web/src/modules/image/generation/view/images.tsx delete mode 100644 apps/web/src/modules/image/generation/view/index.tsx delete mode 100644 apps/web/src/modules/image/history/cta.tsx delete mode 100644 apps/web/src/modules/image/history/index.tsx delete mode 100644 apps/web/src/modules/image/layout/background-grid.tsx delete mode 100644 apps/web/src/modules/image/layout/examples.tsx delete mode 100644 apps/web/src/modules/image/layout/headline.tsx delete mode 100644 apps/web/src/modules/image/lib/api.ts delete mode 100644 apps/web/src/modules/image/use-image-generation.tsx delete mode 100644 apps/web/src/modules/marketing/blog/tags-picker.tsx delete mode 100644 apps/web/src/modules/pdf/components/citation-preview.tsx delete mode 100644 apps/web/src/modules/pdf/components/citation.tsx delete mode 100644 apps/web/src/modules/pdf/components/index.ts delete mode 100644 apps/web/src/modules/pdf/components/navigation-controls.tsx delete mode 100644 apps/web/src/modules/pdf/components/recent-chats.tsx delete mode 100644 apps/web/src/modules/pdf/components/text-selection-action.tsx delete mode 100644 apps/web/src/modules/pdf/composer/index.tsx delete mode 100644 apps/web/src/modules/pdf/composer/use-composer.tsx delete mode 100644 apps/web/src/modules/pdf/context/index.ts delete mode 100644 apps/web/src/modules/pdf/context/pdf-viewer-context.tsx delete mode 100644 apps/web/src/modules/pdf/history/actions.tsx delete mode 100644 apps/web/src/modules/pdf/history/index.tsx delete mode 100644 apps/web/src/modules/pdf/history/list/index.tsx delete mode 100644 apps/web/src/modules/pdf/history/list/item.tsx delete mode 100644 apps/web/src/modules/pdf/hooks/index.ts delete mode 100644 apps/web/src/modules/pdf/hooks/use-citation-unit.ts delete mode 100644 apps/web/src/modules/pdf/hooks/use-embedding.ts delete mode 100644 apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts delete mode 100644 apps/web/src/modules/pdf/index.ts delete mode 100644 apps/web/src/modules/pdf/layout/layout.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/document-menu.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/index.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/page-navigation.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/pdf-viewer.css delete mode 100644 apps/web/src/modules/pdf/layout/preview/text-highlight-layer.tsx delete mode 100644 apps/web/src/modules/pdf/layout/preview/zoom-menu.tsx delete mode 100644 apps/web/src/modules/pdf/lib/api.ts delete mode 100644 apps/web/src/modules/pdf/thread/assistant.tsx delete mode 100644 apps/web/src/modules/pdf/thread/citation-markdown.tsx delete mode 100644 apps/web/src/modules/pdf/thread/copy-with-citations.tsx delete mode 100644 apps/web/src/modules/pdf/thread/follow-up-suggestions.tsx delete mode 100644 apps/web/src/modules/pdf/thread/index.tsx delete mode 100644 apps/web/src/modules/pdf/thread/suggested-questions.tsx delete mode 100644 apps/web/src/modules/pdf/thread/user.tsx delete mode 100644 apps/web/src/modules/pdf/upload/confirm.tsx delete mode 100644 apps/web/src/modules/pdf/upload/hooks/use-upload.tsx delete mode 100644 apps/web/src/modules/pdf/upload/index.tsx delete mode 100644 apps/web/src/modules/pdf/upload/url-form.tsx delete mode 100644 apps/web/src/modules/pdf/upload/utils.ts delete mode 100644 apps/web/src/modules/tts/composer/hooks/use-audio.tsx delete mode 100644 apps/web/src/modules/tts/composer/hooks/use-composer.tsx delete mode 100644 apps/web/src/modules/tts/composer/index.tsx delete mode 100644 apps/web/src/modules/tts/composer/settings.tsx delete mode 100644 apps/web/src/modules/tts/composer/voice-selector.tsx delete mode 100644 apps/web/src/modules/tts/index.tsx delete mode 100644 apps/web/src/modules/tts/speech/avatar.tsx delete mode 100644 apps/web/src/modules/tts/speech/index.tsx delete mode 100644 apps/web/src/modules/tts/speech/voice-visualizer.tsx delete mode 100644 apps/web/src/modules/tts/use-tts.tsx delete mode 100644 apps/web/src/modules/tts/utils/types.ts delete mode 100644 packages/api/src/modules/ai/chat.ts delete mode 100644 packages/api/src/modules/ai/image.ts delete mode 100644 packages/api/src/modules/ai/pdf.ts delete mode 100644 packages/api/src/modules/ai/router.ts delete mode 100644 packages/api/src/modules/ai/stt.ts delete mode 100644 packages/api/src/modules/ai/tts.ts diff --git a/apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx b/apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx deleted file mode 100644 index 64a2c61..0000000 --- a/apps/web/src/app/[locale]/(apps)/chat/[id]/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { notFound, redirect } from "next/navigation"; -import { z } from "zod"; - -import { messageSchema, partSchema } from "@turbostarter/ai/chat/schema"; -import { toChatMessage } from "@turbostarter/ai/chat/utils"; -import { handle } from "@turbostarter/api/utils"; - -import { pathsConfig } from "~/config/paths"; -import { api } from "~/lib/api/server"; -import { getSession } from "~/lib/auth/server"; -import { getMetadata } from "~/lib/metadata"; -import { ViewChat } from "~/modules/chat/layout/view"; - -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ id: string; locale: string }>; -}) => { - const id = (await params).id; - const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({ - param: { id }, - }); - - return getMetadata({ - ...(data?.name && { title: data.name }), - })({ params }); -}; - -export default async function Chat({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { user } = await getSession(); - - if (!user) { - return redirect(pathsConfig.auth.login); - } - - const id = (await params).id; - - const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({ - param: { id }, - }); - - if (!data) { - return notFound(); - } - - const messages = await handle(api.ai.chat.chats[":id"].messages.$get, { - throwOnError: false, - schema: z.array( - messageSchema.extend({ - parts: z.array(partSchema), - }), - ), - })({ - param: { id }, - }); - const initialMessages = (messages ?? []).map(toChatMessage); - - return ; -} diff --git a/apps/web/src/app/[locale]/(apps)/chat/layout.tsx b/apps/web/src/app/[locale]/(apps)/chat/layout.tsx deleted file mode 100644 index eace10b..0000000 --- a/apps/web/src/app/[locale]/(apps)/chat/layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { getMetadata } from "~/lib/metadata"; -import { ChatHistory } from "~/modules/chat/history"; -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; - -export const generateMetadata = getMetadata({ - title: "ai:chat.title", - description: "ai:chat.description", -}); - -export default function ChatLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> -
-
- - -
-
- -
- {children} -
- - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/chat/page.tsx b/apps/web/src/app/[locale]/(apps)/chat/page.tsx deleted file mode 100644 index 28cefa2..0000000 --- a/apps/web/src/app/[locale]/(apps)/chat/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { useMemo } from "react"; - -import { generateId } from "@turbostarter/shared/utils"; - -import { useComposer } from "~/modules/chat/composer/hooks/use-composer"; -import { NewChat } from "~/modules/chat/layout/new"; -import { ViewChat } from "~/modules/chat/layout/view"; - -export default function Chat() { - const id = useMemo(() => generateId(), []); - - const { messages } = useComposer({ - id, - }); - - if (messages.length) { - return ; - } - - return ; -} diff --git a/apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx b/apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx deleted file mode 100644 index 86f0c9a..0000000 --- a/apps/web/src/app/[locale]/(apps)/image/[id]/page.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { notFound } from "next/navigation"; - -import { generationSchema } from "@turbostarter/ai/image/schema"; -import { handle } from "@turbostarter/api/utils"; - -import { api } from "~/lib/api/server"; -import { getMetadata } from "~/lib/metadata"; -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; -import { ViewGeneration } from "~/modules/image/generation/view"; -import { HistoryCta } from "~/modules/image/history/cta"; - -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ id: string; locale: string }>; -}) => { - const id = (await params).id; - const generation = await handle(api.ai.image.generations[":id"].$get)({ - param: { id }, - }); - - return getMetadata({ - ...(generation?.prompt && { - title: - generation.prompt.length > 50 - ? `${generation.prompt.slice(0, 50)}...` - : generation.prompt, - }), - })({ params }); -}; - -export default async function ImageGeneration({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const id = (await params).id; - - const generation = await handle(api.ai.image.generations[":id"].$get, { - schema: generationSchema.nullable(), - })({ - param: { id }, - }); - - if (!generation) { - return notFound(); - } - - const images = await handle(api.ai.image.generations[":id"].images.$get)({ - param: { id }, - }); - - return ( - <> -
-
- - -
-
- ({ - url: image.url, - })), - }} - /> - - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/image/history/page.tsx b/apps/web/src/app/[locale]/(apps)/image/history/page.tsx deleted file mode 100644 index b8cc493..0000000 --- a/apps/web/src/app/[locale]/(apps)/image/history/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { getMetadata } from "~/lib/metadata"; -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; -import { History } from "~/modules/image/history"; - -export const generateMetadata = getMetadata({ - title: "ai:image.history.title", - description: "ai:image.history.description", -}); - -export default function HistoryPage() { - return ( - <> -
-
- -
-
- - - - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/image/layout.tsx b/apps/web/src/app/[locale]/(apps)/image/layout.tsx deleted file mode 100644 index 0a4eb96..0000000 --- a/apps/web/src/app/[locale]/(apps)/image/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getMetadata } from "~/lib/metadata"; - -export const generateMetadata = getMetadata({ - title: "ai:image.title", - description: "ai:image.description", -}); - -export default function ImageLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( -
- {children} -
- ); -} diff --git a/apps/web/src/app/[locale]/(apps)/image/page.tsx b/apps/web/src/app/[locale]/(apps)/image/page.tsx deleted file mode 100644 index 4722841..0000000 --- a/apps/web/src/app/[locale]/(apps)/image/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { useMemo } from "react"; - -import { generateId } from "@turbostarter/shared/utils"; - -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; -import { NewGeneration } from "~/modules/image/generation/new"; -import { ViewGeneration } from "~/modules/image/generation/view"; -import { HistoryCta } from "~/modules/image/history/cta"; -import { useImageGeneration } from "~/modules/image/use-image-generation"; - -const Image = () => { - const id = useMemo(() => generateId(), []); - - const { generation } = useImageGeneration({ - id, - }); - - if (generation) { - return ; - } - - return ; -}; - -export default function Page() { - return ( - <> -
-
- - -
-
- - - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx b/apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx deleted file mode 100644 index 7ab3a4b..0000000 --- a/apps/web/src/app/[locale]/(apps)/pdf/[id]/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { PdfLayout } from "~/modules/pdf/layout/layout"; - -export default async function Layout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ id: string }>; -}) { - const id = (await params).id; - return {children}; -} diff --git a/apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx b/apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx deleted file mode 100644 index 931c8d4..0000000 --- a/apps/web/src/app/[locale]/(apps)/pdf/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import * as z from "zod"; - -import { messageSchema } from "@turbostarter/ai/pdf/schema"; -import { handle } from "@turbostarter/api/utils"; - -import { api } from "~/lib/api/server"; -import { getMetadata } from "~/lib/metadata"; -import { ChatComposer } from "~/modules/pdf/composer"; -import { Chat } from "~/modules/pdf/thread"; - -export const generateMetadata = async ({ - params, -}: { - params: Promise<{ id: string; locale: string }>; -}) => { - const id = (await params).id; - const chat = await handle(api.ai.pdf.chats[":id"].$get)({ - param: { id }, - }); - - return getMetadata({ - ...(chat?.name && { title: chat.name }), - })({ params }); -}; - -const PdfChat = async ({ params }: { params: Promise<{ id: string }> }) => { - const id = (await params).id; - const messages = await handle(api.ai.pdf.chats[":id"].messages.$get, { - schema: z.array(messageSchema), - })({ - param: { id }, - }); - - const initialMessages = messages.map((message) => ({ - ...message, - parts: [ - { - type: "text" as const, - text: message.content, - }, - ], - })); - - return ( - <> - - -
-
- -
-
- - ); -}; - -export default PdfChat; diff --git a/apps/web/src/app/[locale]/(apps)/pdf/layout.tsx b/apps/web/src/app/[locale]/(apps)/pdf/layout.tsx deleted file mode 100644 index 64dd3fe..0000000 --- a/apps/web/src/app/[locale]/(apps)/pdf/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { getMetadata } from "~/lib/metadata"; - -export const generateMetadata = getMetadata({ - title: "ai:pdf.title", - description: "ai:pdf.description", -}); - -export default function PdfLayout({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} diff --git a/apps/web/src/app/[locale]/(apps)/pdf/page.tsx b/apps/web/src/app/[locale]/(apps)/pdf/page.tsx deleted file mode 100644 index 9698edf..0000000 --- a/apps/web/src/app/[locale]/(apps)/pdf/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; -import { RecentChats } from "~/modules/pdf/components/recent-chats"; -import { ChatHistory } from "~/modules/pdf/history"; -import { PdfUpload } from "~/modules/pdf/upload"; - -export default function PdfPage() { - return ( - <> -
-
- - -
-
-
- - -
- - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/tts/layout.tsx b/apps/web/src/app/[locale]/(apps)/tts/layout.tsx deleted file mode 100644 index 60df0fe..0000000 --- a/apps/web/src/app/[locale]/(apps)/tts/layout.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { getMetadata } from "~/lib/metadata"; -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; - -export const generateMetadata = getMetadata({ - title: "ai:tts.title", - description: "ai:tts.description", -}); - -export default function AgentLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> -
-
- -
-
-
- {children} -
- - ); -} diff --git a/apps/web/src/app/[locale]/(apps)/tts/page.tsx b/apps/web/src/app/[locale]/(apps)/tts/page.tsx deleted file mode 100644 index 43cb2cc..0000000 --- a/apps/web/src/app/[locale]/(apps)/tts/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { unstable_cache } from "next/cache"; - -import { getVoices } from "@turbostarter/ai/tts/api"; - -// Skip static generation - requires ELEVENLABS_API_KEY at runtime -export const dynamic = "force-dynamic"; -import { random } from "@turbostarter/shared/utils"; - -import { Tts } from "~/modules/tts"; - -const getCachedVoices = unstable_cache( - async () => { - const voices = await getVoices(); - - return voices.map((voice) => ({ - ...voice, - avatar: { - src: `/images/avatars/${random(1, 3)}.webp`, - style: { - filter: `hue-rotate(${random(0, 360)}deg) saturate(1.2)`, - transform: `rotate(${random(0, 360)}deg)`, - }, - }, - })); - }, - ["voices"], - { - revalidate: 3600 * 24, // Cache for 1 day - tags: ["voices"], - }, -); - -export default async function TtsPage() { - const voices = await getCachedVoices(); - - return ; -} diff --git a/apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx deleted file mode 100644 index af63358..0000000 --- a/apps/web/src/app/[locale]/(marketing)/blog/[slug]/page.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import Image from "next/image"; -import { notFound } from "next/navigation"; - -import { getContentItemBySlug, getContentItems } from "@turbostarter/cms"; -import { CollectionType } from "@turbostarter/cms"; -import { getTranslation } from "@turbostarter/i18n/server"; -import { badgeVariants } from "@turbostarter/ui-web/badge"; - -import { BLOG_PREFIX } from "~/config/paths"; -import { getMetadata } from "~/lib/metadata"; -import { Mdx } from "~/modules/common/mdx"; -import { TurboLink } from "~/modules/common/turbo-link"; -import { - Section, - SectionDescription, - SectionHeader, - SectionTitle, -} from "~/modules/marketing/layout/section"; - -dayjs.extend(duration); - -export default async function Page({ - params, -}: { - params: Promise<{ slug: string; locale: string }>; -}) { - const { t } = await getTranslation({ ns: "marketing" }); - const item = getContentItemBySlug({ - collection: CollectionType.BLOG, - slug: (await params).slug, - locale: (await params).locale, - }); - - if (!item) { - return notFound(); - } - - return ( -
- -
- {item.tags.map((tag) => ( - - {t(`blog.tag.${tag}`)} - - ))} -
- - {item.title} - -
- - - {item.timeToRead && ·} - {typeof item.timeToRead !== "undefined" && ( - - {t("blog.timeToRead", { - time: Math.ceil(dayjs.duration(item.timeToRead).asMinutes()), - })} - - )} -
- - - {item.description} - - -
- -
-
- - -
- ); -} - -export function generateStaticParams() { - return getContentItems({ collection: CollectionType.BLOG }).items.map( - (post) => ({ - slug: post.slug, - }), - ); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ slug: string; locale: string }>; -}) { - const item = getContentItemBySlug({ - collection: CollectionType.BLOG, - slug: (await params).slug, - locale: (await params).locale, - }); - - if (!item) { - return notFound(); - } - - return getMetadata({ - title: item.title, - description: item.description, - })({ params }); -} diff --git a/apps/web/src/app/[locale]/(marketing)/blog/page.tsx b/apps/web/src/app/[locale]/(marketing)/blog/page.tsx deleted file mode 100644 index 3f652d0..0000000 --- a/apps/web/src/app/[locale]/(marketing)/blog/page.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import Image from "next/image"; - -import { - CollectionType, - ContentStatus, - getContentItems, -} from "@turbostarter/cms"; -import { getTranslation } from "@turbostarter/i18n/server"; -import { SortOrder } from "@turbostarter/shared/constants"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@turbostarter/ui-web/card"; - -import { pathsConfig } from "~/config/paths"; -import { getMetadata } from "~/lib/metadata"; -import { TurboLink } from "~/modules/common/turbo-link"; -import { TagsPicker } from "~/modules/marketing/blog/tags-picker"; -import { - Section, - SectionBadge, - SectionDescription, - SectionHeader, - SectionTitle, -} from "~/modules/marketing/layout/section"; - -import type { ContentTag } from "@turbostarter/cms"; - -dayjs.extend(duration); - -export const generateMetadata = getMetadata({ - title: "marketing:blog.label", - description: "marketing:blog.description", - canonical: pathsConfig.marketing.blog.index, -}); - -export default async function BlogPage({ - searchParams, - params, -}: { - searchParams: Promise<{ tag?: ContentTag }>; - params: Promise<{ locale: string }>; -}) { - const tag = (await searchParams).tag; - const locale = (await params).locale; - - const { t } = await getTranslation({ ns: "marketing" }); - const { items } = getContentItems({ - collection: CollectionType.BLOG, - tags: tag ? [tag] : [], - sortBy: "publishedAt", - sortOrder: SortOrder.DESCENDING, - status: ContentStatus.PUBLISHED, - locale, - }); - - return ( -
- - {t("blog.label")} - {t("blog.title")} - {t("blog.description")} - - -
- -
- -
- {items.map((post) => ( - - - -
-
- -
-
- -
- {post.tags.map((tag) => ( - - {t(`blog.tag.${tag}`)} - - ))} -
- {post.title} -
- - · - - {t("blog.timeToRead", { - time: Math.ceil( - dayjs.duration(post.timeToRead).asMinutes(), - ), - })} - -
-
- - -

- {post.description} -

-
-
-
- ))} -
-
- ); -} diff --git a/apps/web/src/modules/chat/composer/components/voice-button.tsx b/apps/web/src/modules/chat/composer/components/voice-button.tsx deleted file mode 100644 index bd4e56a..0000000 --- a/apps/web/src/modules/chat/composer/components/voice-button.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import type { VoiceButtonProps } from "../types"; - -const formatDuration = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = seconds % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; -}; - -const VoiceLevelBars = ({ level }: { level: number }) => { - // Create 3 bars with different thresholds - const bars = [ - { threshold: 10, delay: "0ms" }, - { threshold: 30, delay: "100ms" }, - { threshold: 50, delay: "200ms" }, - ]; - - return ( -
- {bars.map((bar, i) => ( -
bar.threshold ? "opacity-100" : "opacity-30" - )} - style={{ - height: level > bar.threshold ? `${Math.min(12, 4 + (level / 100) * 8)}px` : "4px", - animationDelay: bar.delay, - }} - /> - ))} -
- ); -}; - -export const VoiceButton = ({ - state, - duration, - audioLevel, - disabled = false, - onToggle, - onCancel: _onCancel, -}: VoiceButtonProps) => { - const { t } = useTranslation("common"); - - const isRecording = state === "recording"; - const isProcessing = state === "processing"; - - const getTooltipContent = () => { - if (isRecording) { - return t("pressEscapeToCancel"); - } - if (isProcessing) { - return t("transcribing"); - } - return t("record"); - }; - - return ( - - -
- {/* Recording state indicator - shows duration and level */} - {isRecording && ( -
- - - - - {formatDuration(duration)} - -
- )} - - -
-
- - {getTooltipContent()} - -
- ); -}; - -export default VoiceButton; diff --git a/apps/web/src/modules/chat/composer/dropzone.tsx b/apps/web/src/modules/chat/composer/dropzone.tsx deleted file mode 100644 index 8471e7e..0000000 --- a/apps/web/src/modules/chat/composer/dropzone.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { memo } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { Attachments } from "~/modules/common/ai/composer/attachments"; - -import { useAttachments } from "./hooks/use-attachments"; - -const DropzoneDialog = () => { - const { t } = useTranslation("ai"); - - return ( - - - - {t("chat.composer.files.dropzone.title")} - -

- {t("chat.composer.files.dropzone.description")} -

-
- ); -}; - -interface ChatDropzoneProps { - readonly children: React.ReactNode; - readonly disabled?: boolean; -} - -export const ChatDropzone = memo( - ({ children, disabled }) => { - const { onAdd } = useAttachments(); - - return ( - } - disabled={disabled} - > - {children} - - ); - }, -); - -ChatDropzone.displayName = "ChatDropzone"; diff --git a/apps/web/src/modules/chat/composer/hooks/use-attachments.tsx b/apps/web/src/modules/chat/composer/hooks/use-attachments.tsx deleted file mode 100644 index ef1f88f..0000000 --- a/apps/web/src/modules/chat/composer/hooks/use-attachments.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useCallback } from "react"; -import { toast } from "sonner"; -import * as z from "zod"; -import { create } from "zustand"; - -import { useTranslation } from "@turbostarter/i18n"; -import { generateId } from "@turbostarter/shared/utils"; - -import { uploadWithRetry } from "~/utils"; - -const MAX_FILE_SIZE_IN_MB = 5; -const MAX_FILE_SIZE = MAX_FILE_SIZE_IN_MB * 1024 * 1024; -const MAX_FILES_COUNT = 5; -const ACCEPTED_FILE_TYPES = [ - "image/png", - "image/gif", - "image/jpeg", - "image/webp", - "image/jpg", -]; - -const useValidation = () => { - const { t } = useTranslation(["validation"]); - - const fileSchema = z - .instanceof(File) - .refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), { - message: t("error.file.type", { - type: "image", - }), - }) - .refine((file) => file.size <= MAX_FILE_SIZE, { - message: t("error.tooBig.file.notInclusive", { - size: MAX_FILE_SIZE_IN_MB, - }), - }); - - const validate = (files: File[], attachments: File[]) => { - const errors = new Set(); - Array.from(files).forEach((file) => { - try { - fileSchema.parse(file); - } catch (error) { - if (error instanceof z.ZodError && error.issues[0]) { - errors.add(error.issues[0].message); - } - } - }); - - if (files.length + attachments.length > MAX_FILES_COUNT) { - errors.add( - t("error.file.maxCount", { - count: MAX_FILES_COUNT, - }), - ); - } - - return { - errors, - files: files - .filter((file) => fileSchema.safeParse(file).success) - .slice(0, MAX_FILES_COUNT - attachments.length) - .map((file) => new File([file], generateId(), { type: file.type })), - }; - }; - - return { validate }; -}; - -interface AttachmentsState { - attachments: File[]; - setAttachments: (attachments: File[]) => void; -} - -export const useAttachmentsStore = create((set) => ({ - attachments: [], - setAttachments: (attachments) => set({ attachments }), -})); - -export const useAttachments = () => { - const { validate } = useValidation(); - const { attachments, setAttachments } = useAttachmentsStore(); - - const upload = useMutation({ - mutationFn: async ({ directory }: { directory: string }) => { - setAttachments([]); - await Promise.allSettled( - attachments.map((attachment) => - uploadWithRetry({ - path: `${directory}/${attachment.name}.${ - attachment.type.split("/")[1] ?? "png" - }`, - file: attachment, - }), - ), - ); - }, - onError: (error) => { - console.error(error); - }, - }); - - const onAdd = useCallback( - (files: File[]) => { - const { errors, files: filesToAdd } = validate(files, attachments); - - for (const error of errors) { - toast.error(error); - } - - if (!filesToAdd.length) { - return; - } - - setAttachments([...attachments, ...filesToAdd]); - }, - [attachments, setAttachments, validate], - ); - - const onRemove = useCallback( - (file: File) => { - setAttachments(attachments.filter((a) => a.name !== file.name)); - }, - [attachments, setAttachments], - ); - - const onPaste = useCallback( - (event: React.ClipboardEvent) => { - const items = event.clipboardData.items; - - const files = Array.from(items) - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null); - - if (files.length > 0) { - onAdd(files); - } - }, - [onAdd], - ); - - const onClear = useCallback(() => { - setAttachments([]); - }, [setAttachments]); - - return { attachments, upload, onAdd, onRemove, onPaste, onClear }; -}; diff --git a/apps/web/src/modules/chat/composer/hooks/use-composer.tsx b/apps/web/src/modules/chat/composer/hooks/use-composer.tsx deleted file mode 100644 index 911de33..0000000 --- a/apps/web/src/modules/chat/composer/hooks/use-composer.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useChat } from "@ai-sdk/react"; -import { Chat } from "@ai-sdk/react"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQueryClient } from "@tanstack/react-query"; -import { DefaultChatTransport } from "ai"; -import { useCallback, useEffect, useState } from "react"; -import { useForm, useFormContext } from "react-hook-form"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -import { MODELS } from "@turbostarter/ai/chat/constants"; -import { chatMessageOptionsSchema } from "@turbostarter/ai/chat/schema"; -import { useDebounceCallback } from "@turbostarter/shared/hooks"; - -import { pathsConfig } from "~/config/paths"; -import { api } from "~/lib/api/client"; -import { authClient } from "~/lib/auth/client"; -import { chat as chatApi } from "~/modules/chat/lib/api"; -import { useAIError } from "~/modules/common/hooks/use-ai-error"; -import { useCredits } from "~/modules/common/layout/credits"; - -import { useAttachments } from "./use-attachments"; - -import type { ChatMessageOptionsPayload } from "@turbostarter/ai/chat/schema"; -import type { ChatMessage } from "@turbostarter/ai/chat/types"; -import type { WatchObserver } from "react-hook-form"; - -interface ChatOptionsState { - options: ChatMessageOptionsPayload; - setOptions: (options: Partial) => void; -} - -export const useChatOptions = create()( - persist( - (set) => ({ - options: { - reason: false, - search: false, - model: MODELS[0].id, - }, - setOptions: (options) => - set((state) => ({ - options: { - ...state.options, - ...options, - }, - })), - }), - { - name: "chat-options", - }, - ), -); - -const chats = new Map>(); - -const getChatInstance = ({ - id, - ...options -}: ConstructorParameters>[0]) => { - if (!id || !chats.has(id)) { - const chat = new Chat({ - id, - ...options, - }); - - chats.set(id ?? chat.id, chat); - } - - const instance = chats.get(id ?? ""); - if (!instance) { - throw new Error(`Chat instance with id ${id} not found!`); - } - return instance; -}; - -interface UseComposerProps { - readonly id?: string; - readonly initialMessages?: ChatMessage[]; -} - -export const useComposer = ({ id, initialMessages }: UseComposerProps = {}) => { - const [input, setInput] = useState(""); - - const { onError } = useAIError(); - const { invalidate } = useCredits(); - const { data } = authClient.useSession(); - const queryClient = useQueryClient(); - - const { options, setOptions } = useChatOptions(); - const { attachments, upload, onClear } = useAttachments(); - const newForm = useForm({ - resolver: zodResolver(chatMessageOptionsSchema), - defaultValues: options, - }); - - const contextForm = useFormContext(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const form = contextForm ?? newForm; - - const chat = getChatInstance({ - id, - transport: new DefaultChatTransport({ - api: api.ai.chat.chats.$url().toString(), - prepareSendMessagesRequest: ({ messages, id }) => { - const lastMessage = messages.at(-1); - - const directory = `attachments/${id}/${lastMessage?.id}`; - - upload.mutate({ - directory, - }); - - return { - body: { - ...lastMessage, - chatId: id, - parts: lastMessage?.parts.map((part) => - part.type === "file" - ? { - ...part, - path: `${directory}/${part.filename}.${part.mediaType.split("/")[1] ?? "png"}`, - } - : part, - ), - }, - }; - }, - }), - messages: initialMessages, - onFinish: () => { - void invalidate(); - if (!initialMessages?.length) { - void queryClient.invalidateQueries( - chatApi.queries.chats.user.getAll(data?.user.id ?? ""), - ); - } - }, - onError, - }); - - const { messages, sendMessage, ...rest } = useChat({ - chat, - }); - - const syncOptions: WatchObserver = useCallback( - (values) => setOptions(values), - [setOptions], - ); - - const debouncedSyncOptions = useDebounceCallback(syncOptions, 500); - - useEffect(() => { - const subscription = form.watch(debouncedSyncOptions); - return () => subscription.unsubscribe(); - }, [form, debouncedSyncOptions]); - - const onSubmit = useCallback( - (prompt?: string) => { - const url = pathsConfig.apps.chat.chat(chat.id); - - window.history.replaceState({}, "", url); - - if (prompt) { - return sendMessage({ - text: prompt, - metadata: { - options: chatMessageOptionsSchema.parse(form.getValues()), - }, - }); - } else { - const dataTransfer = new DataTransfer(); - attachments.forEach((attachment) => { - dataTransfer.items.add(attachment); - }); - - void sendMessage({ - text: input, - files: dataTransfer.files, - metadata: { - options: chatMessageOptionsSchema.parse(form.getValues()), - }, - }); - setInput(""); - } - }, - [sendMessage, input, attachments, chat.id, form], - ); - - const model = MODELS.find((model) => model.id === form.watch("model")); - - useEffect(() => { - if (!model?.attachments) { - onClear(); - } - }, [model?.attachments, onClear]); - - return { - messages, - form, - onSubmit, - input, - setInput, - model, - ...rest, - }; -}; diff --git a/apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx b/apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx deleted file mode 100644 index 1e25edc..0000000 --- a/apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { api } from "~/lib/api/client"; - -import type { - UseVoiceRecordingOptions, - UseVoiceRecordingReturn, - VoiceRecordingState, -} from "../types"; - -export const useVoiceRecording = ( - options: UseVoiceRecordingOptions = {} -): UseVoiceRecordingReturn => { - const { onTranscription, onError, onStateChange } = options; - - const [state, setState] = useState("idle"); - const [duration, setDuration] = useState(0); - const [audioLevel, setAudioLevel] = useState(0); - const [error, setError] = useState(null); - - const mediaRecorderRef = useRef(null); - const streamRef = useRef(null); - const chunksRef = useRef([]); - const analyserRef = useRef(null); - const audioContextRef = useRef(null); - const animationFrameRef = useRef(null); - const timerRef = useRef(null); - - // Update state and notify - const updateState = useCallback( - (newState: VoiceRecordingState) => { - setState(newState); - onStateChange?.(newState); - }, - [onStateChange] - ); - - // Cleanup function - const cleanup = useCallback(() => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); - streamRef.current = null; - } - if (audioContextRef.current) { - void audioContextRef.current.close(); - audioContextRef.current = null; - } - analyserRef.current = null; - mediaRecorderRef.current = null; - chunksRef.current = []; - setDuration(0); - setAudioLevel(0); - }, []); - - // Monitor audio levels - const monitorAudioLevel = useCallback(() => { - if (!analyserRef.current) return; - - const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); - analyserRef.current.getByteFrequencyData(dataArray); - - // Calculate average volume (0-100) - const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length; - const normalizedLevel = Math.min(100, Math.round((average / 255) * 100 * 2)); - setAudioLevel(normalizedLevel); - - animationFrameRef.current = requestAnimationFrame(monitorAudioLevel); - }, []); - - // Start recording - const startRecording = useCallback(async () => { - try { - setError(null); - cleanup(); - - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - streamRef.current = stream; - - // Setup audio analysis - const audioContext = new AudioContext(); - audioContextRef.current = audioContext; - const source = audioContext.createMediaStreamSource(stream); - const analyser = audioContext.createAnalyser(); - analyser.fftSize = 256; - source.connect(analyser); - analyserRef.current = analyser; - - // Start audio level monitoring - monitorAudioLevel(); - - // Setup media recorder - const mediaRecorder = new MediaRecorder(stream, { - mimeType: MediaRecorder.isTypeSupported("audio/webm") - ? "audio/webm" - : "audio/mp4", - }); - - mediaRecorderRef.current = mediaRecorder; - chunksRef.current = []; - - mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - chunksRef.current.push(event.data); - } - }; - - mediaRecorder.onstop = async () => { - // Stop level monitoring - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - - console.log("[Voice] Recording stopped, chunks:", chunksRef.current.length); - - if (chunksRef.current.length === 0) { - console.log("[Voice] No chunks recorded, aborting"); - cleanup(); - updateState("idle"); - return; - } - - updateState("processing"); - - try { - const audioBlob = new Blob(chunksRef.current, { - type: mediaRecorder.mimeType, - }); - - console.log("[Voice] Audio blob:", audioBlob.size, "bytes,", mediaRecorder.mimeType); - - const formData = new FormData(); - formData.append( - "audio", - audioBlob, - `recording.${mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4"}` - ); - - const url = api.ai.stt.$url().toString(); - console.log("[Voice] Sending to:", url); - - const response = await fetch(url, { - method: "POST", - body: formData, - credentials: "include", - }); - - console.log("[Voice] Response status:", response.status); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - console.error("[Voice] Error response:", errorData); - throw new Error( - (errorData as { message?: string }).message ?? - "Transcription failed" - ); - } - - const result = (await response.json()) as { text: string }; - console.log("[Voice] Transcription result:", result.text); - onTranscription?.(result.text); - } catch (err) { - console.error("[Voice] Error:", err); - const transcriptionError = - err instanceof Error ? err : new Error("Transcription failed"); - setError(transcriptionError); - onError?.(transcriptionError); - } finally { - cleanup(); - updateState("idle"); - } - }; - - mediaRecorder.start(); - updateState("recording"); - - // Start duration timer - setDuration(0); - timerRef.current = setInterval(() => { - setDuration((prev) => prev + 1); - }, 1000); - } catch (err) { - const accessError = - err instanceof Error - ? err - : new Error("Failed to access microphone"); - setError(accessError); - onError?.(accessError); - cleanup(); - updateState("idle"); - } - }, [cleanup, monitorAudioLevel, onTranscription, onError, updateState]); - - // Stop recording (will trigger transcription) - const stopRecording = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - if (mediaRecorderRef.current?.state === "recording") { - mediaRecorderRef.current.stop(); - } - }, []); - - // Cancel recording (no transcription) - const cancelRecording = useCallback(() => { - cleanup(); - updateState("idle"); - }, [cleanup, updateState]); - - // Toggle recording - const toggleRecording = useCallback(() => { - if (state === "recording") { - stopRecording(); - } else if (state === "idle") { - void startRecording(); - } - }, [state, startRecording, stopRecording]); - - // Escape key handler - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" && state === "recording") { - e.preventDefault(); - cancelRecording(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [state, cancelRecording]); - - // Cleanup on unmount - useEffect(() => { - return () => { - cleanup(); - }; - }, [cleanup]); - - return { - state, - duration, - audioLevel, - error, - isRecording: state === "recording", - isProcessing: state === "processing", - startRecording, - stopRecording, - cancelRecording, - toggleRecording, - }; -}; diff --git a/apps/web/src/modules/chat/composer/index.tsx b/apps/web/src/modules/chat/composer/index.tsx deleted file mode 100644 index 76cbbd1..0000000 --- a/apps/web/src/modules/chat/composer/index.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { toast } from "sonner"; - -import { MODELS } from "@turbostarter/ai/chat/constants"; -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { - Form, - FormControl, - FormField, - FormItem, -} from "@turbostarter/ui-web/form"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { Toggle } from "@turbostarter/ui-web/toggle"; - -import { Composer } from "~/modules/common/ai/composer"; -import { ModelSelector } from "~/modules/common/ai/composer/model-selector"; - -import { VoiceButton } from "./components/voice-button"; -import { useAttachments } from "./hooks/use-attachments"; -import { useComposer } from "./hooks/use-composer"; -import { useVoiceRecording } from "./hooks/use-voice-recording"; - -import type { ChatMessage } from "@turbostarter/ai/chat/types"; - -interface ChatComposerProps { - readonly id?: string; - readonly initialMessages?: ChatMessage[]; -} - -export const ChatComposer = ({ - id, - initialMessages, -}: ChatComposerProps = {}) => { - const { t } = useTranslation(["ai", "common"]); - const { status, stop, form, onSubmit, model, input, setInput } = useComposer({ - id, - initialMessages, - }); - - const { attachments, onRemove, onPaste } = useAttachments(); - - const { - state: voiceState, - duration, - audioLevel, - toggleRecording, - cancelRecording, - } = useVoiceRecording({ - onTranscription: (text) => { - setInput((prev) => (prev ? `${prev} ${text}` : text)); - }, - onError: (error) => { - const message = error.message.includes("microphone") - ? t("microphoneDenied", { ns: "common" }) - : t("transcriptionFailed", { ns: "common" }); - toast.error(message); - }, - }); - - const isSubmitting = ["submitted", "streaming"].includes(status); - - return ( -
- onSubmit())}> - - - - setInput(e.currentTarget.value)} - maxLength={5_000} - placeholder={t("chat.composer.placeholder")} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - - if (!isSubmitting) { - return form.handleSubmit(() => onSubmit())(); - } - } - }} - onPaste={onPaste} - /> - -
- - -
- ( - - - - - - {t("search.label")} - - - - - )} - /> - - ( - - - - - - {t("reason")} - - - - - )} - /> -
- - - - - - -
-
-
-
- ); -}; diff --git a/apps/web/src/modules/chat/composer/types.ts b/apps/web/src/modules/chat/composer/types.ts deleted file mode 100644 index 0360cec..0000000 --- a/apps/web/src/modules/chat/composer/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Voice Recording Types - -export type VoiceRecordingState = "idle" | "recording" | "processing"; - -export interface VoiceRecordingData { - state: VoiceRecordingState; - duration: number; // seconds elapsed - audioLevel: number; // 0-100 volume level - error: Error | null; -} - -export interface UseVoiceRecordingOptions { - onTranscription?: (text: string) => void; - onError?: (error: Error) => void; - onStateChange?: (state: VoiceRecordingState) => void; -} - -export interface UseVoiceRecordingReturn { - state: VoiceRecordingState; - duration: number; - audioLevel: number; - error: Error | null; - isRecording: boolean; - isProcessing: boolean; - startRecording: () => Promise; - stopRecording: () => void; - cancelRecording: () => void; - toggleRecording: () => void; -} - -export interface VoiceButtonProps { - state: VoiceRecordingState; - duration: number; - audioLevel: number; - disabled?: boolean; - onToggle: () => void; - onCancel: () => void; -} diff --git a/apps/web/src/modules/chat/history/actions.tsx b/apps/web/src/modules/chat/history/actions.tsx deleted file mode 100644 index b9e3b4b..0000000 --- a/apps/web/src/modules/chat/history/actions.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useTranslation } from "@turbostarter/i18n"; -import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { pathsConfig } from "~/config/paths"; -import { TurboLink } from "~/modules/common/turbo-link"; - -interface ChatActionsProps { - onSelect: () => void; -} - -export const ChatActions = ({ onSelect }: ChatActionsProps) => { - const { t } = useTranslation(["common", "ai"]); - - return ( - - - - - {t("chat.new")} - - - - ); -}; diff --git a/apps/web/src/modules/chat/history/index.tsx b/apps/web/src/modules/chat/history/index.tsx deleted file mode 100644 index 5579731..0000000 --- a/apps/web/src/modules/chat/history/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { useState, useEffect } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { - CommandDialog, - CommandEmpty, - CommandInput, - CommandList, -} from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { ChatActions } from "./actions"; -import { ChatHistoryList } from "./list"; - -dayjs.extend(duration); -dayjs.extend(relativeTime); - -interface CommandMenuProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => { - const { t } = useTranslation("ai"); - - return ( - - - - {t("chat.command.empty")} - onOpenChange(false)} /> - onOpenChange(false)} /> - - - ); -}; - -export const ChatHistory = () => { - const { t } = useTranslation("common"); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setIsOpen((open) => !open); - } - }; - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, []); - - return ( - <> - - - - - - - {t("history")} - - {/* eslint-disable-next-line i18next/no-literal-string */} - K - - - - - - - - ); -}; diff --git a/apps/web/src/modules/chat/history/list/index.tsx b/apps/web/src/modules/chat/history/list/index.tsx deleted file mode 100644 index e9aa0fa..0000000 --- a/apps/web/src/modules/chat/history/list/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { useTranslation } from "@turbostarter/i18n"; -import { useDateGroups } from "@turbostarter/shared/hooks"; -import { CommandGroup } from "@turbostarter/ui-web/command"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; - -import { authClient } from "~/lib/auth/client"; - -import { chat } from "../../lib/api"; - -import { ChatHistoryListItem } from "./item"; - -interface ChatHistoryListProps { - onSelect: () => void; -} - -export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => { - const { t } = useTranslation("common"); - const { data: session } = authClient.useSession(); - const userChats = useQuery( - chat.queries.chats.user.getAll(session?.user.id ?? ""), - ); - - const groups = useDateGroups(userChats.data ?? []); - - if (userChats.isLoading) { - return ( - - - - - - ); - } - - return ( - <> - {groups.map( - (group) => - group.items.length > 0 && ( - - {group.items.map((chat) => ( - - ))} - - ), - )} - - ); -}; diff --git a/apps/web/src/modules/chat/history/list/item.tsx b/apps/web/src/modules/chat/history/list/item.tsx deleted file mode 100644 index 7381fe3..0000000 --- a/apps/web/src/modules/chat/history/list/item.tsx +++ /dev/null @@ -1,167 +0,0 @@ -"use client"; - -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import dayjs from "dayjs"; -import { usePathname, useRouter } from "next/navigation"; -import { toast } from "sonner"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { Button } from "@turbostarter/ui-web/button"; -import { CommandItem } from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { pathsConfig } from "~/config/paths"; -import { authClient } from "~/lib/auth/client"; -import { TurboLink } from "~/modules/common/turbo-link"; - -import { chat as chatApi } from "../../lib/api"; - -import type { Chat } from "@turbostarter/ai/chat/types"; - -interface ChatHistoryListItemProps { - readonly chat: Chat; - readonly onSelect: () => void; -} - -export const ChatHistoryListItem = ({ - chat, - onSelect, -}: ChatHistoryListItemProps) => { - const { t } = useTranslation("common"); - - const router = useRouter(); - const pathname = usePathname(); - - return ( - { - router.push(pathsConfig.apps.chat.chat(chat.id)); - onSelect(); - }} - className="group" - > -
- - - {chat.name} - {pathname.includes(chat.id) && ( - {t("current")} - )} - - -
-
- ); -}; - -const Controls = ({ chat }: { chat: Chat }) => { - const { data: session } = authClient.useSession(); - const userId = session?.user.id ?? ""; - const { t } = useTranslation("common"); - - const router = useRouter(); - const pathname = usePathname(); - - const queryClient = useQueryClient(); - const { mutate } = useMutation({ - ...chatApi.mutations.chats.delete, - onMutate: async (data) => { - await queryClient.cancelQueries({ - queryKey: chatApi.queries.chats.user.getAll(userId).queryKey, - }); - - const previousChats = queryClient.getQueryData( - chatApi.queries.chats.user.getAll(userId).queryKey, - ); - - queryClient.setQueryData( - chatApi.queries.chats.user.getAll(userId).queryKey, - (old: Chat[]) => old.filter((chat) => chat.id !== data.id), - ); - - if (pathname.includes(chat.id)) { - router.push(pathsConfig.apps.chat.index); - } - - return { previousChats }; - }, - onError: (error, _, context) => { - toast.error(error.message); - queryClient.setQueryData( - chatApi.queries.chats.user.getAll(userId).queryKey, - context?.previousChats, - ); - }, - onSettled: async () => { - await queryClient.invalidateQueries( - chatApi.queries.chats.user.getAll(userId), - ); - }, - }); - - return ( - <> - - {dayjs(chat.createdAt).fromNow()} - - -
- - - - - - -

{t("newTab")}

-
-
-
- - - - - - - -

{t("delete")}

-
-
-
-
- - ); -}; diff --git a/apps/web/src/modules/chat/layout/examples.tsx b/apps/web/src/modules/chat/layout/examples.tsx deleted file mode 100644 index 9d03cf5..0000000 --- a/apps/web/src/modules/chat/layout/examples.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { memo } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { useComposer } from "~/modules/chat/composer/hooks/use-composer"; - -const examples = [ - { - icon: Icons.FileText, - label: "chat.example.summarize.label", - prompt: "chat.example.summarize.prompt", - }, - { - icon: Icons.ChartNoAxesColumn, - label: "chat.example.analyze.label", - prompt: "chat.example.analyze.prompt", - }, - { - icon: Icons.Code, - label: "chat.example.code.label", - prompt: "chat.example.code.prompt", - }, - { - icon: Icons.Zap, - label: "chat.example.brainstorm.label", - prompt: "chat.example.brainstorm.prompt", - }, - { - icon: Icons.PackageOpen, - label: "chat.example.surprise.label", - prompt: "chat.example.surprise.prompt", - }, -] as const; - -interface ExamplesProps { - readonly id?: string; - readonly className?: string; -} - -export const Examples = memo(({ className, id }) => { - const { t } = useTranslation("ai"); - const { onSubmit } = useComposer({ id }); - - return ( -
- {examples.map(({ icon: Icon, label, prompt }, index) => ( - - - - ))} -
- ); -}); - -Examples.displayName = "Examples"; diff --git a/apps/web/src/modules/chat/layout/headline.tsx b/apps/web/src/modules/chat/layout/headline.tsx deleted file mode 100644 index 428cc13..0000000 --- a/apps/web/src/modules/chat/layout/headline.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useTranslation } from "@turbostarter/i18n"; -import { getGreeting } from "@turbostarter/shared/utils"; - -export const Headline = () => { - const { t } = useTranslation(["common", "ai"]); - const { text, emoji } = getGreeting(); - - return ( -

- {t(`greeting.${text}`)} {emoji} - {t("ai:chat.headline")} -

- ); -}; diff --git a/apps/web/src/modules/chat/layout/new.tsx b/apps/web/src/modules/chat/layout/new.tsx deleted file mode 100644 index 4774d2b..0000000 --- a/apps/web/src/modules/chat/layout/new.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { memo } from "react"; - -import { ChatComposer } from "~/modules/chat/composer"; -import { ChatDropzone } from "~/modules/chat/composer/dropzone"; -import { useComposer } from "~/modules/chat/composer/hooks/use-composer"; -import { Examples } from "~/modules/chat/layout/examples"; -import { Headline } from "~/modules/chat/layout/headline"; - -interface NewChatProps { - id: string; -} - -export const NewChat = memo(({ id }) => { - const { model } = useComposer({ id }); - return ( - -
-
- -
-
- -
- -
-
-
-
- ); -}); - -NewChat.displayName = "NewChat"; diff --git a/apps/web/src/modules/chat/layout/view.tsx b/apps/web/src/modules/chat/layout/view.tsx deleted file mode 100644 index 6ff9c2f..0000000 --- a/apps/web/src/modules/chat/layout/view.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { memo } from "react"; - -import { ChatComposer } from "~/modules/chat/composer"; -import { ChatDropzone } from "~/modules/chat/composer/dropzone"; -import { Chat } from "~/modules/chat/thread"; - -import { useComposer } from "../composer/hooks/use-composer"; - -import type { ChatMessage } from "@turbostarter/ai/chat/types"; - -interface ViewChatProps { - readonly id: string; - readonly initialMessages?: ChatMessage[]; -} - -export const ViewChat = memo(({ id, initialMessages }) => { - const { model } = useComposer({ id, initialMessages }); - - return ( - - - -
-
- -
-
-
- ); -}); - -ViewChat.displayName = "ViewChat"; diff --git a/apps/web/src/modules/chat/lib/api.ts b/apps/web/src/modules/chat/lib/api.ts deleted file mode 100644 index 30b141e..0000000 --- a/apps/web/src/modules/chat/lib/api.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as z from "zod"; - -import { chatSchema } from "@turbostarter/ai/chat/schema"; -import { handle } from "@turbostarter/api/utils"; - -import { api } from "~/lib/api/client"; - -const KEY = "chat"; - -const queries = { - chats: { - user: { - getAll: (userId: string) => ({ - queryKey: [KEY, "chats", userId], - queryFn: handle(api.ai.chat.chats.$get, { - schema: z.array(chatSchema), - }), - }), - }, - }, -}; - -const mutations = { - chats: { - delete: { - mutationKey: [KEY, "chats", "delete"], - mutationFn: ({ id }: { id: string }) => - handle(api.ai.chat.chats[":id"].$delete)({ - param: { id }, - }), - }, - }, -}; - -export const chat = { - queries, - mutations, -} as const; diff --git a/apps/web/src/modules/chat/thread/index.tsx b/apps/web/src/modules/chat/thread/index.tsx deleted file mode 100644 index 1552c2b..0000000 --- a/apps/web/src/modules/chat/thread/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { Role } from "@turbostarter/ai/chat/types"; - -import { Thread } from "../../common/ai/thread"; -import { useComposer } from "../composer/hooks/use-composer"; - -import { AssistantMessage } from "./message/assistant"; -import { UserMessage } from "./message/user"; - -import type { ChatMessage } from "@turbostarter/ai/chat/types"; - -interface ChatProps { - readonly id?: string; - readonly initialMessages?: ChatMessage[]; -} - -const components = { - [Role.USER]: UserMessage, - [Role.ASSISTANT]: AssistantMessage, -}; - -export const Chat = ({ id, initialMessages }: ChatProps = {}) => { - const { messages, regenerate, error, status } = useComposer({ - id, - initialMessages, - }); - - return ( - - ); -}; diff --git a/apps/web/src/modules/chat/thread/message/assistant/index.tsx b/apps/web/src/modules/chat/thread/message/assistant/index.tsx deleted file mode 100644 index 4aef2d6..0000000 --- a/apps/web/src/modules/chat/thread/message/assistant/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { memo } from "react"; - -import { WebSearch } from "~/modules/chat/thread/message/assistant/tools/web-search"; -import { ThreadMessage } from "~/modules/common/ai/thread/message"; -import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown"; -import { Prose } from "~/modules/common/prose"; - -import { ReasoningMessagePart } from "./reasoning"; - -import type { ChatMessage } from "@turbostarter/ai/chat/types"; -import type { ThreadMessageProps } from "~/modules/common/ai/thread/message"; - -export const AssistantMessage = memo>( - ({ message, ref, status }) => { - return ( - - - {message.parts.map((part, partIndex) => { - switch (part.type) { - case "text": - return ( - - ); - case "reasoning": - return ( - - ); - - case "tool-web-search": - switch (part.state) { - case "input-available": - case "output-available": - return ( - p.type === "data-query_completion", - )} - /> - ); - } - } - })} - - - {!["submitted", "streaming"].includes(status) && ( - - )} - - ); - }, -); - -AssistantMessage.displayName = "AssistantMessage"; diff --git a/apps/web/src/modules/chat/thread/message/assistant/reasoning.tsx b/apps/web/src/modules/chat/thread/message/assistant/reasoning.tsx deleted file mode 100644 index dd7c1f2..0000000 --- a/apps/web/src/modules/chat/thread/message/assistant/reasoning.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@turbostarter/ui-web/accordion"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown"; - -import type { ReasoningUIPart } from "ai"; - -interface ReasoningMessagePartProps { - part: ReasoningUIPart; - reasoning: boolean; - defaultExpanded?: boolean; -} - -export function ReasoningMessagePart({ - part, - reasoning, - defaultExpanded = false, -}: ReasoningMessagePartProps) { - const { t } = useTranslation("common"); - - if (!part.text) { - return null; - } - - return ( -
- - - -
-
-
- {reasoning ? ( - - ) : ( - - )} -
-

- {reasoning - ? t("reasoning.inProgress") - : t("reasoning.completed")} -

-
-
-
- - -
-
- - - -
-
-
-
-
-
- ); -} diff --git a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/images.tsx b/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/images.tsx deleted file mode 100644 index 6d1b1e0..0000000 --- a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/images.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState } from "react"; - -import { cn } from "@turbostarter/ui"; -import { useBreakpoint } from "@turbostarter/ui-web"; - -import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image"; - -import type { SearchResult } from "."; - -type SearchImage = SearchResult["images"][number]; - -export const PREVIEW_IMAGE_COUNT = { - MOBILE: 4, - DESKTOP: 5, -}; - -interface ImageGridProps { - images: SearchImage[]; - showAll?: boolean; -} - -const ImageThumbnail = ({ - image, - index, - onClick, - isLast, - hasMore, - moreCount, -}: { - image: SearchImage; - index: number; - onClick: () => void; - isLast: boolean; - hasMore: boolean; - moreCount: number; -}) => ( - - - {image.description && (!isLast || !hasMore) && ( -
-

{image.description}

-
- )} - {isLast && hasMore && ( -
- +{moreCount} -
- )} -
-); - -export const ImageGrid = ({ images, showAll = false }: ImageGridProps) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedImage, setSelectedImage] = useState(0); - const isDesktop = useBreakpoint("md"); - - const displayImages = showAll - ? images - : images.slice( - 0, - isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE, - ); - const hasMore = - images.length > - (isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE); - - return ( -
-
*:first-child]:col-span-1 [&>*:first-child]:row-span-1", - isDesktop && - displayImages.length > 1 && - "[&>*:first-child]:col-span-2 [&>*:first-child]:row-span-2", - displayImages.length === 1 && - "grid-cols-1! [&>*:first-child]:col-span-1! [&>*:first-child]:row-span-2!", - )} - > - {displayImages.map((image, index) => ( - { - setSelectedImage(index); - setIsOpen(true); - }} - isLast={index === displayImages.length - 1} - hasMore={!showAll && hasMore} - moreCount={images.length - displayImages.length} - /> - ))} -
- - -
- ); -}; diff --git a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/index.tsx b/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/index.tsx deleted file mode 100644 index b9243a3..0000000 --- a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/index.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import { motion } from "motion/react"; -import { useState } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@turbostarter/ui-web/accordion"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { ImageGrid } from "./images"; -import { SearchLoading } from "./loading"; - -import type { - ChatDataParts, - ChatTools, - Tool, -} from "@turbostarter/ai/chat/types"; -import type { DataUIPart } from "ai"; - -const ResultCard = ({ - result, -}: { - result: SearchResult["results"][number]; -}) => { - const [imageLoaded, setImageLoaded] = useState(false); - - return ( -
-
-
-
- {!imageLoaded && ( -
- )} - setImageLoaded(true)} - onError={(e) => { - setImageLoaded(true); - e.currentTarget.src = - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='16'/%3E%3Cline x1='8' y1='12' x2='16' y2='12'/%3E%3C/svg%3E"; - }} - /> -
- -
- -

- {result.content} -

- - {result.publishedDate && ( -
- -
- )} -
-
- ); -}; - -export type SearchResult = NonNullable< - ChatTools[typeof Tool.WEB_SEARCH]["output"] ->["searches"][number]; - -export const WebSearch = ( - props: Partial & { - annotations: DataUIPart[]; - }, -) => { - const { input, output, annotations } = props; - const { t } = useTranslation("common"); - - if (!output) { - return ( - - ); - } - - const allImages = output.searches.reduce( - (acc, search) => { - return [...acc, ...search.images]; - }, - [], - ); - - const totalResults = output.searches.reduce( - (sum, search) => sum + search.results.length, - 0, - ); - - return ( -
- - - -
-
-
- -
-

- {t("search.completed")} -

-
-
- - - {totalResults} {t("results")} - -
-
-
- - -
-
- {output.searches.map((search, i) => ( - - - {search.query.q} - - ))} -
- -
- {output.searches.map((search) => - search.results.map((result, resultIndex) => ( - - - - )), - )} -
-
-
-
-
- - {allImages.length > 0 && } -
- ); -}; diff --git a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/loading.tsx b/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/loading.tsx deleted file mode 100644 index 2aeebf3..0000000 --- a/apps/web/src/modules/chat/thread/message/assistant/tools/web-search/loading.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { useBreakpoint } from "@turbostarter/ui-web"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@turbostarter/ui-web/accordion"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; - -import { PREVIEW_IMAGE_COUNT } from "./images"; - -import type { - ChatTools, - ChatDataParts, - Tool, -} from "@turbostarter/ai/chat/types"; -import type { DataUIPart } from "ai"; - -export const SearchLoading = ({ - queries, - annotations, -}: { - queries: ChatTools[typeof Tool.WEB_SEARCH]["input"]["queries"]; - annotations: DataUIPart[]; -}) => { - const isDesktop = useBreakpoint("md"); - const { t } = useTranslation("common"); - const totalResults = annotations.reduce( - (sum, a) => sum + a.data.resultsCount, - 0, - ); - - return ( -
- - - -
-
-
- -
-

- {t("search.inProgress")} -

-
-
- - - {totalResults || "0"} {t("results")} - -
-
-
- - -
-
- {queries.map((query, i) => { - const annotation = annotations.find( - (a) => - a.data.query.q === query.q && - a.data.status === "completed", - ); - - return ( - - {annotation ? ( - - ) : ( - - )} - {query.q} - - ); - })} -
- -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
- -
- - -
-
-
- - - -
-
-
- ))} -
-
-
-
-
- -
- {Array.from({ - length: isDesktop - ? PREVIEW_IMAGE_COUNT.DESKTOP - : PREVIEW_IMAGE_COUNT.MOBILE, - }).map((_, i) => ( - - ))} -
-
- ); -}; diff --git a/apps/web/src/modules/chat/thread/message/user/index.tsx b/apps/web/src/modules/chat/thread/message/user/index.tsx deleted file mode 100644 index 1ad1dcc..0000000 --- a/apps/web/src/modules/chat/thread/message/user/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { memo, useState } from "react"; - -import { ThreadMessage } from "~/modules/common/ai/thread/message"; -import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image"; -import { Prose } from "~/modules/common/prose"; - -import type { ChatMessage } from "@turbostarter/ai/chat/types"; -import type { FileUIPart } from "ai"; -import type { ThreadMessageProps } from "~/modules/common/ai/thread/message"; - -const Attachments = ({ attachments }: { attachments: FileUIPart[] }) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedImage, setSelectedImage] = useState(0); - - if (!attachments.length) { - return null; - } - - return ( - <> -
- {attachments - .filter((attachment) => attachment.mediaType.includes("image/")) - .map((attachment, index) => { - return ( - { - setIsOpen(true); - setSelectedImage(index); - }} - className="aspect-square h-24 w-24 border bg-transparent shadow-none sm:h-32 sm:w-32 dark:bg-transparent" - > - - - ); - })} -
- - - - ); -}; - -export const UserMessage = memo>( - ({ message, ref }) => { - const attachments = message.parts.filter((part) => part.type === "file"); - return ( - - {attachments.length > 0 && ( - - )} - {message.parts.map((part, index) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - } - })} - - ); - }, -); - -UserMessage.displayName = "UserMessage"; diff --git a/apps/web/src/modules/common/ai/composer/attachments.tsx b/apps/web/src/modules/common/ai/composer/attachments.tsx deleted file mode 100644 index 9deade6..0000000 --- a/apps/web/src/modules/common/ai/composer/attachments.tsx +++ /dev/null @@ -1,227 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { AnimatePresence } from "motion/react"; -import { createContext, memo, useContext, useMemo } from "react"; -import { useState } from "react"; -import { useDropzone } from "react-dropzone"; -import { toast } from "sonner"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@turbostarter/ui-web/avatar"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { Viewer } from "~/modules/common/image"; - -import type { DropzoneOptions, DropzoneState } from "react-dropzone"; - -const DropzoneContext = createContext<{ - dropzone: DropzoneState; -} | null>(null); - -interface DropzoneProps extends DropzoneOptions { - children: React.ReactNode; - dialog?: React.ReactNode; -} - -const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => { - const dropzone = useDropzone({ - accept: { - "image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"], - }, - onError: (error) => toast.error(error.message), - noClick: true, - noKeyboard: true, - multiple: true, - ...options, - }); - - return ( - -
- {children} - - - {dropzone.isDragActive && dialog && ( -
- - - {dialog} -
- )} -
-
-
- ); -}; - -const Input = memo>((props) => { - const { t } = useTranslation(["ai", "common"]); - const context = useContext(DropzoneContext); - - return ( - <> - - - - - - - - {t("chat.composer.files.add")} - - - - - ); -}); - -Input.displayName = "Input"; - -interface PreviewProps extends React.HTMLAttributes { - attachments: File[]; - onRemove: (file: File) => void; -} - -export const Preview = memo( - ({ attachments, onRemove, className, ...props }) => { - const [isOpen, setIsOpen] = useState(false); - const [selectedImage, setSelectedImage] = useState(0); - - if (!attachments.length) { - return null; - } - - return ( - <> -
- {attachments.map((attachment, index) => ( - onRemove(attachment)} - onClick={() => { - setSelectedImage(index); - setIsOpen(true); - }} - /> - ))} -
- - ({ - url: URL.createObjectURL(attachment), - }))} - selectedImage={selectedImage} - setSelectedImage={setSelectedImage} - /> - - ); - }, -); - -Preview.displayName = "Preview"; - -interface ThumbnailProps extends React.HTMLAttributes { - attachment: File; - onRemove: () => void; -} - -const Thumbnail = memo(({ attachment, onRemove, ...props }) => { - const { t } = useTranslation(["ai"]); - const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]); - - return ( -
- - - - - - - - - {t("chat.composer.files.remove")} - - - -
- ); -}); - -Thumbnail.displayName = "Thumbnail"; - -export const Attachments = { - Input, - Dropzone, - Preview, -}; diff --git a/apps/web/src/modules/common/ai/composer/index.tsx b/apps/web/src/modules/common/ai/composer/index.tsx deleted file mode 100644 index 89d68e4..0000000 --- a/apps/web/src/modules/common/ai/composer/index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useRef } from "react"; - -import { cn } from "@turbostarter/ui"; -import { TextareaAutosize } from "@turbostarter/ui-web/textarea"; - -import { Attachments } from "./attachments"; - -const Form = ({ - className, - children, - ...props -}: React.HTMLAttributes) => { - const ref = useRef(null); - - useEffect(() => { - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - ref.current - ?.closest("main") - ?.style.setProperty( - "--composer-height", - `${entry.contentRect.height}px`, - ); - }); - - if (ref.current) { - resizeObserver.observe(ref.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - return ( -
- {children} -
- ); -}; - -const Input = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( -
- ); -}; - -const Textarea = ({ - className, - ...props -}: Omit, "style">) => { - return ( - - ); -}; - -export const Composer = { - Form, - Input, - Textarea, - Attachments, -}; diff --git a/apps/web/src/modules/common/ai/composer/model-selector.tsx b/apps/web/src/modules/common/ai/composer/model-selector.tsx deleted file mode 100644 index e97de43..0000000 --- a/apps/web/src/modules/common/ai/composer/model-selector.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form"; -import { - Select, - SelectContent, - SelectItem, - SelectPortal, - SelectTrigger, - SelectValue, -} from "@turbostarter/ui-web/select"; - -import { ProviderIcons } from "~/modules/common/ai/icons"; - -import type { Provider } from "@turbostarter/ai"; -import type { Control, FieldValues, Path } from "react-hook-form"; - -interface ModelSelectorProps { - readonly control: Control; - readonly name: Path; - readonly options: readonly { - readonly id: string; - readonly name: string; - readonly provider: Provider; - }[]; -} - -export const ModelSelector = ({ - name, - control, - options, -}: ModelSelectorProps) => { - return ( - ( - - - - - - )} - /> - ); -}; diff --git a/apps/web/src/modules/common/ai/icons.tsx b/apps/web/src/modules/common/ai/icons.tsx deleted file mode 100644 index 76b1112..0000000 --- a/apps/web/src/modules/common/ai/icons.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Provider } from "@turbostarter/ai"; -import { Icons } from "@turbostarter/ui-web/icons"; - -export const ProviderIcons = { - [Provider.OPENAI]: Icons.OpenAI, - [Provider.GEMINI]: Icons.Gemini, - [Provider.CLAUDE]: Icons.Claude, - [Provider.GROK]: Icons.Grok, - [Provider.DEEPSEEK]: Icons.DeepSeek, - [Provider.REPLICATE]: Icons.Replicate, - [Provider.LUMA]: Icons.Luma, - [Provider.STABILITY_AI]: Icons.StabilityAI, - [Provider.RECRAFT]: Icons.Recraft, - [Provider.ELEVEN_LABS]: Icons.ElevenLabs, - [Provider.NVIDIA]: Icons.Nvidia, -}; diff --git a/apps/web/src/modules/common/ai/thread/analyzing-image.tsx b/apps/web/src/modules/common/ai/thread/analyzing-image.tsx deleted file mode 100644 index bb1a651..0000000 --- a/apps/web/src/modules/common/ai/thread/analyzing-image.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { motion } from "motion/react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { TextShimmer } from "@turbostarter/ui-web/text-shimmer"; - -import type { Transition } from "motion/react"; - -const transition: Transition = { - duration: 2.5, - ease: [0.175, 0.885, 0.32, 1], - times: [0, 0.6, 0.6, 1], - repeat: Infinity, - repeatType: "mirror", - repeatDelay: 0.2, -}; - -export const AnalyzingImage = () => { - const { t } = useTranslation("common"); - return ( -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - {t("analyzingImage")} - -
- ); -}; diff --git a/apps/web/src/modules/common/ai/thread/controls/copy.tsx b/apps/web/src/modules/common/ai/thread/controls/copy.tsx deleted file mode 100644 index 3cb8694..0000000 --- a/apps/web/src/modules/common/ai/thread/controls/copy.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; - -import { getMessageTextContent } from "@turbostarter/ai"; -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { useCopy } from "~/modules/common/hooks/use-copy"; - -import type { UIMessage } from "@ai-sdk/react"; - -const transition = { - initial: { opacity: 0, scale: 0.8 }, - animate: { opacity: 1, scale: 1 }, - exit: { opacity: 0, scale: 0.8 }, - transition: { duration: 0.1, ease: "easeInOut" as const }, -}; - -interface ThreadMessageCopyProps { - message: MESSAGE; -} - -export const ThreadMessageCopy = ({ - message, -}: ThreadMessageCopyProps) => { - const { t } = useTranslation("common"); - const { copied, copy } = useCopy(); - - return ( - - - - - - -

{t("copy")}

-
-
-
- ); -}; diff --git a/apps/web/src/modules/common/ai/thread/controls/index.tsx b/apps/web/src/modules/common/ai/thread/controls/index.tsx deleted file mode 100644 index a37764b..0000000 --- a/apps/web/src/modules/common/ai/thread/controls/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { cn } from "@turbostarter/ui"; - -import { ThreadMessageCopy } from "./copy"; -import { ThreadMessageLikes } from "./likes"; - -import type { UIMessage } from "@ai-sdk/react"; - -interface ControlsProps { - message: UIMessage; -} - -export const Controls = ({ message }: ControlsProps) => { - return ( -
- {message.parts.some( - (part) => part.type === "text" && part.text.length > 0, - ) && } - -
- ); -}; diff --git a/apps/web/src/modules/common/ai/thread/controls/likes.tsx b/apps/web/src/modules/common/ai/thread/controls/likes.tsx deleted file mode 100644 index a81416b..0000000 --- a/apps/web/src/modules/common/ai/thread/controls/likes.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -export const ThreadMessageLikes = () => { - const { t } = useTranslation("common"); - const [likeState, setLikeState] = useState<-1 | 0 | 1>(0); - - return ( - - - - - - -

{t("like")}

-
-
- - - - - - -

{t("dislike")}

-
-
-
- ); -}; diff --git a/apps/web/src/modules/common/ai/thread/hooks/use-thread-layout.tsx b/apps/web/src/modules/common/ai/thread/hooks/use-thread-layout.tsx deleted file mode 100644 index d8d14eb..0000000 --- a/apps/web/src/modules/common/ai/thread/hooks/use-thread-layout.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -import { Role } from "@turbostarter/ai/chat/types"; - -import type { UIMessage } from "@ai-sdk/react"; - -interface UseThreadLayoutProps { - readonly messages: MESSAGE[]; - readonly initialMessages?: MESSAGE[]; -} - -export const useThreadLayout = ({ - messages, - initialMessages, -}: UseThreadLayoutProps) => { - const [scrolledByUser, setScrolledByUser] = useState(false); - - const lastMessage = messages.at(-1); - const lastMessageRef = useRef(null); - const isChatActive = initialMessages?.length !== messages.length; - - const lastUserMessageIndex = [...messages] - .reverse() - .findIndex((m) => m.role === Role.USER); - const lastResponseMessages = messages.slice( - lastUserMessageIndex !== 0 ? -2 : -1, - ); - const previousMessages = messages.slice(0, -lastResponseMessages.length); - - useEffect(() => { - if (!lastMessageRef.current) return; - - const parent = lastMessageRef.current.parentElement; - let timeoutId: NodeJS.Timeout; - - const handleScroll = () => { - setScrolledByUser(true); - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - setScrolledByUser(false); - }, 1000); - }; - - parent?.addEventListener("scroll", handleScroll); - - return () => { - parent?.removeEventListener("scroll", handleScroll); - clearTimeout(timeoutId); - }; - }, [lastMessageRef]); - - useEffect(() => { - if (!lastMessageRef.current) return; - - const parent = lastMessageRef.current.parentElement; - - const isAtBottom = () => { - const container = parent?.closest("[data-radix-scroll-area-viewport]"); - - if (!container) return false; - - const scrollBottom = container.scrollTop + container.clientHeight; - return Math.abs(container.scrollHeight - scrollBottom) < 150; - }; - - if (isChatActive) { - if (lastMessage?.role === Role.USER) { - requestAnimationFrame(() => { - parent?.scrollIntoView({ - behavior: "smooth", - block: "end", - }); - }); - } else if (isAtBottom() && !scrolledByUser) { - requestAnimationFrame(() => { - parent?.scrollIntoView({ - behavior: "instant", - block: "end", - }); - }); - } - return; - } - - const animationFrameId = requestAnimationFrame(() => { - parent?.scrollIntoView({ - behavior: "smooth", - block: "end", - }); - }); - - return () => cancelAnimationFrame(animationFrameId); - }, [lastMessage, scrolledByUser, isChatActive]); - - return { - lastMessage, - lastMessageRef, - isChatActive, - lastResponseMessages, - previousMessages, - }; -}; diff --git a/apps/web/src/modules/common/ai/thread/index.tsx b/apps/web/src/modules/common/ai/thread/index.tsx deleted file mode 100644 index 0696280..0000000 --- a/apps/web/src/modules/common/ai/thread/index.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; - -import { Role } from "@turbostarter/ai/chat/types"; -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; - -import { AnalyzingImage } from "./analyzing-image"; -import { useThreadLayout } from "./hooks/use-thread-layout"; -import { ThreadMessage } from "./message"; - -import type { ThreadMessageComponents } from "./message"; -import type { UIMessage } from "@ai-sdk/react"; - -interface ThreadProps { - readonly messages: MESSAGE[]; - readonly initialMessages?: MESSAGE[]; - readonly status: string; - readonly error?: Error | null; - readonly regenerate?: () => Promise; - readonly className?: string; - readonly components: ThreadMessageComponents; - readonly footer?: React.ReactNode; -} - -export const Thread = ({ - messages, - initialMessages, - status, - error, - regenerate, - className, - components, - footer, -}: ThreadProps) => { - const { t } = useTranslation("common"); - const isReloading = useRef(false); - - const { - lastMessage, - lastMessageRef, - isChatActive, - previousMessages, - lastResponseMessages, - } = useThreadLayout({ messages, initialMessages }); - - useEffect(() => { - if ( - messages.at(-1)?.role === Role.USER && - status === "ready" && - !isReloading.current - ) { - isReloading.current = true; - void regenerate?.().finally(() => { - isReloading.current = false; - }); - } - }, [regenerate, messages, status]); - - const renderMessage = useCallback( - (message: MESSAGE) => { - return ( - - ); - }, - [lastMessage?.id, lastMessageRef, status, components], - ); - - return ( - -
- {previousMessages.map(renderMessage)} -
- {lastResponseMessages.map(renderMessage)} - {["submitted", "streaming"].includes(status) && ( -
- {status === "submitted" && - messages.at(-1)?.role === Role.USER && - messages - .at(-1) - ?.parts.some( - (part) => - part.type === "file" && part.mediaType.startsWith("image"), - ) ? ( - - ) : ( - - )} -
- )} - {footer} - {error && ( -
-
-

- {t("error.general")} -

- -
-
- )} -
-
-
-
- ); -}; diff --git a/apps/web/src/modules/common/ai/thread/message.tsx b/apps/web/src/modules/common/ai/thread/message.tsx deleted file mode 100644 index 1e66a80..0000000 --- a/apps/web/src/modules/common/ai/thread/message.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { cn } from "@turbostarter/ui"; - -import { Controls } from "./controls"; - -import type { UIMessage } from "@ai-sdk/react"; - -export type ThreadMessageComponents = Record< - string, - React.ComponentType> ->; - -export interface ThreadMessageProps { - readonly status: string; - readonly message: T; - readonly ref?: React.RefObject; -} - -const Message = ( - props: ThreadMessageProps & { - components: ThreadMessageComponents; - }, -) => { - const role = props.message.role; - - const isSupportedRole = ( - role: string, - ): role is keyof typeof props.components => { - return role in props.components; - }; - - if (!isSupportedRole(role)) { - return null; - } - - const Component = props.components[role]; - - if (!Component) { - return null; - } - - return ; -}; - -const Layout = ({ - children, - className, - ...props -}: React.ComponentProps<"div">) => { - return ( -
- {children} -
- ); -}; - -export const ThreadMessage = { - Layout, - Message, - Controls, -}; diff --git a/apps/web/src/modules/image/composer/aspect-selector.tsx b/apps/web/src/modules/image/composer/aspect-selector.tsx deleted file mode 100644 index 4f80234..0000000 --- a/apps/web/src/modules/image/composer/aspect-selector.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { AspectRatio } from "@turbostarter/ai/image/types"; -import { useTranslation } from "@turbostarter/i18n"; -import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Select, - SelectContent, - SelectItem, - SelectPortal, - SelectTrigger, - SelectValue, -} from "@turbostarter/ui-web/select"; - -import type { Control, FieldValues, Path } from "react-hook-form"; - -const icons = { - [AspectRatio.SQUARE]: Icons.Square, - [AspectRatio.STANDARD]: Icons.Square, - [AspectRatio.LANDSCAPE]: Icons.RectangleHorizontal, - [AspectRatio.PORTRAIT]: Icons.RectangleVertical, -}; - -interface AspectSelectorProps { - readonly control: Control; - readonly name: Path; - readonly options: readonly { - readonly id: AspectRatio; - readonly value: string; - }[]; -} - -export const AspectSelector = ({ - name, - control, - options, -}: AspectSelectorProps) => { - const { t } = useTranslation("common"); - return ( - ( - - - - - - )} - /> - ); -}; diff --git a/apps/web/src/modules/image/composer/image-count-selector.tsx b/apps/web/src/modules/image/composer/image-count-selector.tsx deleted file mode 100644 index eafe7a8..0000000 --- a/apps/web/src/modules/image/composer/image-count-selector.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Select, - SelectContent, - SelectItem, - SelectPortal, - SelectTrigger, - SelectValue, -} from "@turbostarter/ui-web/select"; - -import type { Control, FieldValues, Path } from "react-hook-form"; - -interface ImageCountSelectorProps { - readonly control: Control; - readonly name: Path; - readonly min?: number; - readonly max?: number; -} - -export const ImageCountSelector = ({ - name, - control, - min = 1, - max = 5, -}: ImageCountSelectorProps) => { - return ( - ( - - - - - - )} - /> - ); -}; diff --git a/apps/web/src/modules/image/composer/index.tsx b/apps/web/src/modules/image/composer/index.tsx deleted file mode 100644 index 2fb3575..0000000 --- a/apps/web/src/modules/image/composer/index.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -import { MODELS } from "@turbostarter/ai/image/constants"; -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { - Form, - FormControl, - FormField, - FormItem, -} from "@turbostarter/ui-web/form"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { Composer } from "~/modules/common/ai/composer"; -import { ModelSelector } from "~/modules/common/ai/composer/model-selector"; - -import { AspectSelector } from "./aspect-selector"; -import { ImageCountSelector } from "./image-count-selector"; -import { useComposer } from "./use-composer"; - -interface ImageComposerProps { - id?: string; - prompt?: string; - reset?: () => void; -} - -export const ImageComposer = ({ - id, - prompt: initialPrompt, - reset, -}: ImageComposerProps) => { - const { t } = useTranslation(["ai", "common"]); - const { form, model, onSubmit } = useComposer({ id }); - - const prompt = form.watch("prompt"); - - useEffect(() => { - if (initialPrompt) { - form.setValue("prompt", initialPrompt); - form.setFocus("prompt"); - reset?.(); - } - }, [initialPrompt, form, reset]); - - return ( -
- - - ( - - - { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - return form.handleSubmit(onSubmit)(); - } - }} - /> - - - )} - /> - -
-
- - m.id === model)?.dimensions ?? []} - /> -
- - - - -
-
-
-
- ); -}; diff --git a/apps/web/src/modules/image/composer/use-composer.tsx b/apps/web/src/modules/image/composer/use-composer.tsx deleted file mode 100644 index f329724..0000000 --- a/apps/web/src/modules/image/composer/use-composer.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback, useEffect, useRef } from "react"; -import { useForm, useFormContext } from "react-hook-form"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -import { MODELS } from "@turbostarter/ai/image/constants"; -import { imageGenerationSchema } from "@turbostarter/ai/image/schema"; -import { useDebounceCallback } from "@turbostarter/shared/hooks"; -import { generateId } from "@turbostarter/shared/utils"; - -import { useImageGeneration } from "~/modules/image/use-image-generation"; - -import type { - ImageGenerationOptionsPayload, - ImageGenerationPayload, -} from "@turbostarter/ai/image/schema"; -import type { WatchObserver } from "react-hook-form"; - -interface ImageComposerState { - prompt: string; - options: ImageGenerationOptionsPayload; - setPrompt: (prompt: string) => void; - setOptions: (options: Partial) => void; - reset: () => void; -} - -const DEFAULT_OPTIONS = { - model: MODELS[0].id, - aspectRatio: MODELS[0].dimensions[0].id, - count: 1, -}; - -const useImageComposerStore = create()( - persist( - (set) => ({ - prompt: "", - options: DEFAULT_OPTIONS, - setPrompt: (prompt) => set({ prompt }), - setOptions: (options) => - set((state) => ({ - options: { ...state.options, ...options }, - })), - reset: () => - set({ - prompt: "", - options: DEFAULT_OPTIONS, - }), - }), - { - name: "image-options", - partialize: (state) => ({ options: state.options }), - }, - ), -); - -interface UseComposerProps { - id?: string; -} - -export const useComposer = ({ id: passedId }: UseComposerProps = {}) => { - const { prompt, options, setPrompt, setOptions, reset } = - useImageComposerStore(); - const currentId = useRef(passedId); - - const { createGeneration } = useImageGeneration({ - id: currentId.current, - }); - - useEffect(() => { - if (currentId.current !== passedId) { - reset(); - currentId.current = passedId ?? generateId(); - } - }, [passedId, reset]); - - const newForm = useForm({ - resolver: zodResolver(imageGenerationSchema), - defaultValues: { - prompt, - options, - }, - }); - const contextForm = useFormContext(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const form = contextForm ?? newForm; - - const model = form.watch("options.model"); - - const sync: WatchObserver = useCallback( - (values) => { - setPrompt(values.prompt ?? ""); - setOptions(values.options ?? DEFAULT_OPTIONS); - }, - [setOptions, setPrompt], - ); - - const debouncedSync = useDebounceCallback(sync, 500, { - leading: true, - }); - - useEffect(() => { - const subscription = form.watch(debouncedSync); - return () => subscription.unsubscribe(); - }, [form, debouncedSync]); - - const onSubmit = (input: ImageGenerationPayload) => { - form.resetField("prompt"); - createGeneration.mutate(input); - }; - - return { - form, - model, - prompt, - onSubmit, - reset, - }; -}; diff --git a/apps/web/src/modules/image/generation/new.tsx b/apps/web/src/modules/image/generation/new.tsx deleted file mode 100644 index 77d4935..0000000 --- a/apps/web/src/modules/image/generation/new.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { memo, useState } from "react"; - -import { ImageComposer } from "../composer"; -import { BackgroundGrid } from "../layout/background-grid"; -import { Examples } from "../layout/examples"; -import { Headline } from "../layout/headline"; - -interface NewGenerationProps { - readonly id: string; -} - -export const NewGeneration = memo(({ id }) => { - const [prompt, setPrompt] = useState(""); - return ( - <> - -
-
- -
-
- -
- setPrompt("")} - /> -
-
-
- - ); -}); - -NewGeneration.displayName = "NewImageGeneration"; diff --git a/apps/web/src/modules/image/generation/view/details.tsx b/apps/web/src/modules/image/generation/view/details.tsx deleted file mode 100644 index 02dd64f..0000000 --- a/apps/web/src/modules/image/generation/view/details.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { MODELS } from "@turbostarter/ai/image/constants"; -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@turbostarter/ui-web/popover"; - -import type { ImageGeneration } from "../../use-image-generation"; - -interface DetailsProps { - readonly generation: ImageGeneration; -} - -export const Details = ({ generation }: DetailsProps) => { - const { t, i18n } = useTranslation("common"); - - const model = MODELS.find( - (model) => model.id === generation.input?.options.model, - ); - - if (!model) { - return null; - } - - const aspectRatio = model.dimensions.find( - (dimension) => dimension.id === generation.input?.options.aspectRatio, - ); - - const data = [ - { - label: t("model"), - value: model.name, - }, - { - label: t("aspectRatio"), - value: generation.input?.options.aspectRatio - ? `${t(generation.input.options.aspectRatio)} (${aspectRatio?.value})` - : "---", - }, - { - label: t("count"), - value: generation.input?.options.count, - }, - { - label: t("createdAt"), - value: generation.createdAt?.toLocaleString(i18n.language), - }, - { - label: t("completedAt"), - value: generation.completedAt?.toLocaleString(i18n.language) ?? "---", - }, - ]; - - return ( - - - - - - {data.map((item) => ( -
- {item.label} - {item.value} -
- ))} -
-
- ); -}; diff --git a/apps/web/src/modules/image/generation/view/images.tsx b/apps/web/src/modules/image/generation/view/images.tsx deleted file mode 100644 index 5c81897..0000000 --- a/apps/web/src/modules/image/generation/view/images.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { - createContext, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { toast } from "sonner"; - -import { AspectRatio } from "@turbostarter/ai/image/types"; -import { useTranslation } from "@turbostarter/i18n"; -import { splitArray } from "@turbostarter/shared/utils"; -import { cn } from "@turbostarter/ui"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { Button, buttonVariants } from "@turbostarter/ui-web/button"; -import { GridPattern } from "@turbostarter/ui-web/grid-pattern"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { pathsConfig } from "~/config/paths"; -import { - getImageSource, - Thumbnail, - ThumbnailImage, - Viewer, -} from "~/modules/common/image"; -import { TurboLink } from "~/modules/common/turbo-link"; -import { shareOrDownload } from "~/utils"; - -import type { ImageGenerationImage } from "../../use-image-generation"; - -const getAspectRatioClass = (aspectRatio?: AspectRatio) => { - switch (aspectRatio) { - case AspectRatio.SQUARE: - return "aspect-square"; - case AspectRatio.STANDARD: - return "aspect-[4/3]"; - case AspectRatio.PORTRAIT: - return "aspect-[9/16]"; - case AspectRatio.LANDSCAPE: - return "aspect-[16/9]"; - default: - return "aspect-square"; - } -}; - -const ColumnsContext = createContext(0); - -const Layout = ({ children }: { children: React.ReactNode }) => { - const [columns, setColumns] = useState(0); - const ref = useRef(null); - - const getColumnsCount = () => { - if (!ref.current) { - return 0; - } - - setColumns( - window - .getComputedStyle(ref.current) - .getPropertyValue("grid-template-columns") - .split(" ").length, - ); - }; - - useEffect(() => { - if (!ref.current) { - return; - } - - getColumnsCount(); - const resizeObserver = new ResizeObserver(getColumnsCount); - resizeObserver.observe(ref.current); - return () => resizeObserver.disconnect(); - }, []); - - return ( - -
- {children} -
-
- ); -}; - -interface GridProps { - readonly images: (ImageGenerationImage & { - generationId: string; - description?: string; - aspectRatio?: AspectRatio; - model?: string; - })[]; - readonly fetching?: boolean; - readonly withDetails?: boolean; -} - -const Grid = ({ images, fetching, withDetails }: GridProps) => { - const { t } = useTranslation(["ai", "common"]); - const [isOpen, setIsOpen] = useState(false); - const [selectedImage, setSelectedImage] = useState(0); - const columns = useContext(ColumnsContext); - - const share = useMutation({ - mutationFn: (image: (typeof images)[number]) => - shareOrDownload({ - url: getImageSource(image), - filename: `${image.model ?? "image"}-${Date.now()}.png`, - }), - onError: () => toast.error(t("error.general")), - }); - - const chunks = useMemo(() => splitArray(images, columns), [images, columns]); - - return ( - <> - {chunks.map((chunk, chunkIndex) => ( -
- {chunk.map((image, imageIndex) => { - const index = images.findIndex( - (img) => - (img.url && img.url === image.url) ?? - (img.base64 && img.base64 === image.base64), - ); - return ( -
- { - setIsOpen(true); - setSelectedImage(index); - }} - className={getAspectRatioClass(image.aspectRatio)} - > - {withDetails && ( - - {image.model} - - )} - - - - - - - - - {t("download")} - - - - - {withDetails && ( - - - - - - - - - {t("image.generation.goTo")} - - - - )} -
- ); - })} - - {fetching && ( - - )} -
- ))} - - - - ); -}; - -const Empty = () => { - const { t } = useTranslation(["ai"]); - - return ( -
- - - - - {t("image.generation.empty.title")} - -

- {t("image.generation.empty.description")} -

- - - - {t("image.generation.new")} - -
- ); -}; - -const Loading = ({ - aspectRatio, - count, -}: { - aspectRatio?: AspectRatio; - count?: number; -}) => { - const columns = useContext(ColumnsContext); - - return ( - <> - {Array.from({ length: count ?? columns * 2 }).map((_, index) => ( - - ))} - - ); -}; - -const Error = ({ onRetry }: { onRetry: () => void }) => { - const { t } = useTranslation(["ai", "common"]); - - return ( -
- - - - {t("error.title")} - -

- {t("error.general")} -

- -
- ); -}; - -export const Images = { - Layout, - Grid, - Empty, - Loading, - Error, -}; diff --git a/apps/web/src/modules/image/generation/view/index.tsx b/apps/web/src/modules/image/generation/view/index.tsx deleted file mode 100644 index 87f2b43..0000000 --- a/apps/web/src/modules/image/generation/view/index.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { MODELS } from "@turbostarter/ai/image/constants"; -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; - -import { ProviderIcons } from "~/modules/common/ai/icons"; -import { Stopwatch } from "~/modules/common/stopwatch"; - -import { useImageGeneration } from "../../use-image-generation"; - -import { Details } from "./details"; -import { Images } from "./images"; - -import type { ImageGeneration } from "../../use-image-generation"; - -interface ViewGenerationProps { - id: string; - initialGeneration?: ImageGeneration; -} - -export const ViewGeneration = ({ - id, - initialGeneration, -}: ViewGenerationProps) => { - const { t } = useTranslation(["common", "ai"]); - const { generation, stop, reload } = useImageGeneration({ - id, - initialGeneration, - }); - - if (!generation) { - return null; - } - - const model = MODELS.find( - (model) => model.id === generation.input?.options.model, - ); - - const Icon = model ? ProviderIcons[model.provider] : null; - - return ( - -
-
-
-
-
- {Icon && } - {model?.name} -
- - {generation.createdAt && !generation.completedAt && ( - - )} - {generation.completedAt && - ( - (generation.completedAt.getTime() - - (generation.createdAt?.getTime() ?? 0)) / - 1000 - ).toFixed(1)} - {`s`} - -
- -
-
- - {generation.completedAt ? ( - - ) : ( - - )} -
-
- -

- “{generation.input?.prompt}” -

-
- - {["created", "loading"].includes(generation.status ?? "") ? ( - - - - ) : generation.status === "error" ? ( - - ) : generation.images?.length ? ( - - ({ - ...image, - generationId: id, - description: generation.input?.prompt, - aspectRatio: generation.input?.options.aspectRatio, - model: generation.input?.options.model, - }))} - /> - - ) : ( - - )} -
-
- ); -}; diff --git a/apps/web/src/modules/image/history/cta.tsx b/apps/web/src/modules/image/history/cta.tsx deleted file mode 100644 index ba7473a..0000000 --- a/apps/web/src/modules/image/history/cta.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { useTranslation } from "node_modules/@turbostarter/i18n/src/client"; - -import { cn } from "@turbostarter/ui"; -import { buttonVariants } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { pathsConfig } from "~/config/paths"; -import { TurboLink } from "~/modules/common/turbo-link"; - -export const HistoryCta = () => { - const { t } = useTranslation("common"); - - return ( - - - - - - - - - {t("history")} - - - - ); -}; diff --git a/apps/web/src/modules/image/history/index.tsx b/apps/web/src/modules/image/history/index.tsx deleted file mode 100644 index bc0db04..0000000 --- a/apps/web/src/modules/image/history/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useInfiniteQuery } from "@tanstack/react-query"; -import { useEffect } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { buttonVariants } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { ScrollArea } from "@turbostarter/ui-web/scroll-area"; - -import { pathsConfig } from "~/config/paths"; -import { authClient } from "~/lib/auth/client"; -import { useIntersectionObserver } from "~/modules/common/hooks/use-intersection-observer"; -import { TurboLink } from "~/modules/common/turbo-link"; - -import { Images } from "../generation/view/images"; -import { image } from "../lib/api"; - -const Headline = () => { - const { t } = useTranslation("ai"); - - return ( -
-
-

{t("image.history.title")}

- - - - {t("image.generation.new")} - -
-

- {t("image.history.description")} -

-
- ); -}; - -const Layout = ({ children }: { children: React.ReactNode }) => { - return ( - -
- {children} -
-
- ); -}; - -const Content = () => { - const { data: session } = authClient.useSession(); - - const { isIntersecting, ref } = useIntersectionObserver({ - threshold: 0.5, - }); - - const { - data, - isLoading, - isFetchingNextPage, - fetchNextPage, - isError, - hasNextPage, - refetch, - } = useInfiniteQuery({ - ...image.queries.images.user.getAll(session?.user.id ?? ""), - getNextPageParam: (lastPage) => lastPage.at(-1)?.createdAt, - initialPageParam: undefined, - }); - - useEffect(() => { - if (isIntersecting && hasNextPage && !isFetchingNextPage) { - void fetchNextPage(); - } - }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]); - - const images = data?.pages.flatMap((page) => page) ?? []; - - if (isLoading) { - return ( - - - - ); - } - - if (isError) { - return refetch()} />; - } - - if (!images.length) { - return ; - } - - return ( - <> - - ({ - ...image, - ...image.generation, - description: image.generation.prompt, - }))} - fetching={isFetchingNextPage} - withDetails - /> - - -
- - ); -}; - -export const History = () => { - return ( - - - - - ); -}; diff --git a/apps/web/src/modules/image/layout/background-grid.tsx b/apps/web/src/modules/image/layout/background-grid.tsx deleted file mode 100644 index 5857e0e..0000000 --- a/apps/web/src/modules/image/layout/background-grid.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Image from "next/image"; - -import { cn } from "@turbostarter/ui"; -import { Marquee } from "@turbostarter/ui-web/marquee"; - -const images = [ - "https://images.unsplash.com/photo-1493612276216-ee3925520721", - "https://images.unsplash.com/photo-1731964877414-217cdc9b5b37", - "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634", - "https://images.unsplash.com/photo-1485550409059-9afb054cada4", - "https://images.unsplash.com/photo-1459411552884-841db9b3cc2a", - "https://images.unsplash.com/photo-1726455083595-fb3d23fa3d2d", - "https://images.unsplash.com/photo-1494059980473-813e73ee784b", - "https://images.unsplash.com/photo-1741515277598-64b4da5d212a", - "https://images.unsplash.com/photo-1524856949007-80db29955b17", - "https://images.unsplash.com/photo-1605142859862-978be7eba909", - "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee", - "https://images.unsplash.com/photo-1536697246787-1f7ae568d89a", - "https://images.unsplash.com/photo-1501426026826-31c667bdf23d", - "https://images.unsplash.com/photo-1554570731-63bcddda4dcd", - "https://images.unsplash.com/photo-1504275107627-0c2ba7a43dba", - "https://images.unsplash.com/photo-1741533699135-b3ef83e27215", - "https://images.unsplash.com/photo-1740532501882-5766c265f637", - "https://images.unsplash.com/photo-1560963619-c9e49c9380bd", - "https://images.unsplash.com/photo-1624239408355-7b06ee576e95", - "https://images.unsplash.com/photo-1468971050039-be99497410af", -]; - -const chunkSize = Math.ceil(images.length / 4); -const firstRow = images.slice(0, chunkSize); -const secondRow = images.slice(chunkSize, chunkSize * 2); -const thirdRow = images.slice(chunkSize * 2, chunkSize * 3); -const fourthRow = images.slice(chunkSize * 3); - -const ImageCard = ({ src }: { src: string }) => { - return ( -
- -
- ); -}; - -export function BackgroundGrid() { - return ( -
-
-
- - {firstRow.map((src, index) => ( - - ))} - -
-
- - {secondRow.map((src, index) => ( - - ))} - -
-
- - {thirdRow.map((src, index) => ( - - ))} - -
-
- - {fourthRow.map((src, index) => ( - - ))} - -
-
-
-
- ); -} diff --git a/apps/web/src/modules/image/layout/examples.tsx b/apps/web/src/modules/image/layout/examples.tsx deleted file mode 100644 index c06db1f..0000000 --- a/apps/web/src/modules/image/layout/examples.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { motion } from "motion/react"; -import { memo } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; - -interface ExamplesProps { - readonly className?: string; - readonly onSelect: (prompt: string) => void; -} - -const examples = [ - { - label: "image.example.fox.label", - prompt: "image.example.fox.prompt", - }, - { - label: "image.example.penguin.label", - prompt: "image.example.penguin.prompt", - }, - { - label: "image.example.raccoon.label", - prompt: "image.example.raccoon.prompt", - }, - { - label: "image.example.elephant.label", - prompt: "image.example.elephant.prompt", - }, - { - label: "image.example.dolphin.label", - prompt: "image.example.dolphin.prompt", - }, -] as const; - -export const Examples = memo(({ className, onSelect }) => { - const { t } = useTranslation("ai"); - - return ( -
- {examples.map(({ label, prompt }, index) => ( - - - - ))} -
- ); -}); - -Examples.displayName = "Examples"; diff --git a/apps/web/src/modules/image/layout/headline.tsx b/apps/web/src/modules/image/layout/headline.tsx deleted file mode 100644 index 30c0fa9..0000000 --- a/apps/web/src/modules/image/layout/headline.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useTranslation } from "@turbostarter/i18n"; - -export const Headline = () => { - const { t } = useTranslation("ai"); - - return ( -

- {t("image.headline.title")} - - {t("image.headline.subtitle")} - -

- ); -}; diff --git a/apps/web/src/modules/image/lib/api.ts b/apps/web/src/modules/image/lib/api.ts deleted file mode 100644 index fdb645a..0000000 --- a/apps/web/src/modules/image/lib/api.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { handle } from "@turbostarter/api/utils"; - -import { api } from "~/lib/api/client"; - -import type { InferRequestType } from "hono/client"; - -const KEY = "image"; - -const queries = { - images: { - user: { - getAll: (userId: string) => ({ - queryKey: [KEY, "images", userId], - queryFn: ({ pageParam }: { pageParam: string | undefined }) => - handle(api.ai.image.images.$get)({ - query: { - cursor: pageParam, - }, - }), - }), - }, - }, -}; - -const mutations = { - generations: { - create: { - mutationKey: [KEY, "generations", "create"], - mutationFn: ( - json: InferRequestType["json"], - ) => - handle(api.ai.image.generations.$post)({ - json, - }), - }, - }, -}; - -export const image = { - queries, - mutations, -} as const; diff --git a/apps/web/src/modules/image/use-image-generation.tsx b/apps/web/src/modules/image/use-image-generation.tsx deleted file mode 100644 index 80ea77d..0000000 --- a/apps/web/src/modules/image/use-image-generation.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; -import { create } from "zustand"; - -import { handle } from "@turbostarter/api/utils"; -import { generateId } from "@turbostarter/shared/utils"; - -import { pathsConfig } from "~/config/paths"; -import { api } from "~/lib/api/client"; -import { useAIError } from "~/modules/common/hooks/use-ai-error"; -import { useCredits } from "~/modules/common/layout/credits"; - -import { image } from "./lib/api"; - -import type { ImageGenerationPayload } from "@turbostarter/ai/image/schema"; - -export type ImageGenerationStatus = - | "idle" - | "created" - | "loading" - | "success" - | "error"; - -export interface ImageGenerationImage { - url?: string; - base64?: string; -} - -export interface ImageGeneration { - createdAt?: Date | null; - completedAt?: Date | null; - input?: ImageGenerationPayload; - images?: ImageGenerationImage[]; - status?: ImageGenerationStatus; - error?: Error; - abortController?: AbortController; -} - -interface ImageGenerationStore { - generations: Record; - updateGeneration: (id: string, updates: Partial) => void; -} - -const useImageGenerationStore = create()((set) => ({ - generations: {}, - updateGeneration: (id, updates) => - set((state) => { - const existing = state.generations[id] ?? {}; - - return { - generations: { - ...state.generations, - [id]: { - ...existing, - ...updates, - }, - }, - }; - }), -})); - -interface UseImageGenerationProps { - readonly id?: string; - readonly initialGeneration?: ImageGeneration; -} - -const generationLocks = new Map(); - -export const useImageGeneration = ({ - id: passedId, - initialGeneration, -}: UseImageGenerationProps) => { - const { onError: onAIError } = useAIError(); - - const { invalidate } = useCredits(); - const id = passedId ?? generateId(); - const generation = useImageGenerationStore( - (state) => state.generations[id] ?? null, - ); - - const updateGeneration = useImageGenerationStore( - (state) => state.updateGeneration, - ); - - const update = useCallback( - (updates: Partial) => updateGeneration(id, updates), - [id, updateGeneration], - ); - - const onError = (error: Error) => { - onAIError(error); - update({ - status: "error", - error, - completedAt: new Date(), - }); - }; - - const createGeneration = useMutation({ - ...image.mutations.generations.create, - mutationFn: (input: ImageGenerationPayload) => { - return handle(api.ai.image.generations.$post)({ - json: { - ...input, - id, - }, - }); - }, - onMutate: (input) => { - const url = pathsConfig.apps.image.generation(id); - - window.history.replaceState({}, "", url); - - update({ - status: "loading", - createdAt: new Date(), - input, - }); - }, - onSuccess: () => { - void invalidate(); - update({ - status: "created", - }); - }, - onError, - }); - - const { mutateAsync } = useMutation({ - mutationFn: async () => { - const abortController = new AbortController(); - - update({ - abortController, - status: "loading", - }); - - return handle(api.ai.image.generations[":id"].images.$post)( - { - param: { - id, - }, - }, - { - init: { - signal: abortController.signal, - }, - }, - ); - }, - onSuccess: (images) => { - void invalidate(); - update({ - status: "success", - images: images.map((image) => ({ - base64: image, - })), - }); - }, - onError, - onSettled: () => { - update({ - completedAt: new Date(), - }); - }, - }); - - const stop = useCallback(() => { - if (generation?.abortController) { - generation.abortController.abort(); - - update({ - abortController: undefined, - status: "idle", - completedAt: new Date(), - }); - } - }, [generation?.abortController, update]); - - const reload = useCallback(() => { - update({ - createdAt: new Date(), - completedAt: undefined, - status: "created", - images: [], - }); - }, [update]); - - useEffect(() => { - if (initialGeneration) { - updateGeneration(id, initialGeneration); - } - }, [initialGeneration, id, updateGeneration]); - - useEffect(() => { - if ( - generation?.status === "created" && - !generation.completedAt && - !generationLocks.get(id) - ) { - generationLocks.set(id, true); - void mutateAsync().finally(() => { - generationLocks.delete(id); - }); - } - }, [generation?.status, generation?.completedAt, mutateAsync, id]); - - useEffect(() => { - return () => { - generationLocks.delete(id); - }; - }, [id]); - - return { - generation, - update, - createGeneration, - stop, - reload, - }; -}; diff --git a/apps/web/src/modules/marketing/blog/tags-picker.tsx b/apps/web/src/modules/marketing/blog/tags-picker.tsx deleted file mode 100644 index 6540a50..0000000 --- a/apps/web/src/modules/marketing/blog/tags-picker.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { parseAsStringLiteral, useQueryState } from "nuqs"; - -import { ContentTag } from "@turbostarter/cms"; -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; - -export const TagsPicker = () => { - const { t } = useTranslation("marketing"); - const [activeTag, setActiveTag] = useQueryState("tag", { - ...parseAsStringLiteral(Object.values(ContentTag)), - shallow: false, - }); - - return ( -
- {Object.values(ContentTag).map((tag) => ( - - ))} -
- ); -}; diff --git a/apps/web/src/modules/pdf/components/citation-preview.tsx b/apps/web/src/modules/pdf/components/citation-preview.tsx deleted file mode 100644 index 763b302..0000000 --- a/apps/web/src/modules/pdf/components/citation-preview.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable i18next/no-literal-string */ -"use client"; - -import { cn } from "@turbostarter/ui"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { useEmbedding } from "../hooks"; - -import type { Citation } from "@turbostarter/ai/pdf/types"; -import type { ReactNode } from "react"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface CitationPreviewProps { - /** Citation data with excerpt and metadata */ - citation: Citation; - /** The citation element to wrap (trigger) */ - children: ReactNode; - /** Side to show the tooltip (default: top) */ - side?: "top" | "bottom" | "left" | "right"; - /** Optional className for content styling */ - className?: string; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** - * Format relevance score as percentage - */ -function formatRelevance(score: number): string { - return `${Math.round(score * 100)}%`; -} - -/** - * Truncate excerpt to max length with ellipsis - */ -function truncateExcerpt(excerpt: string, maxLength = 120): string { - if (excerpt.length <= maxLength) return excerpt; - return excerpt.substring(0, maxLength).trim() + "..."; -} - -// ============================================================================ -// Citation Preview Component -// ============================================================================ - -/** - * Tooltip/popover wrapper showing citation details on hover. - * Displays page number, relevance score, and excerpt preview. - * Fetches embedding content if excerpt is not available. - * - * @example - * ```tsx - * - * - * - * ``` - */ -export function CitationPreview({ - citation, - children, - side = "top", - className, -}: CitationPreviewProps) { - // Fetch embedding content if excerpt is empty - const { data: embedding, isLoading } = useEmbedding( - citation.excerpt ? null : citation.embeddingId, - ); - - // Use citation excerpt if available, otherwise use fetched embedding content - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const excerptText = citation.excerpt || embedding?.content || ""; - - return ( - - {children} - -
- {/* Header with page number and relevance */} -
- - Page {citation.pageNumber} - - {citation.relevance > 0 && ( - = 0.8 - ? "bg-green-500/10 text-green-600" - : citation.relevance >= 0.5 - ? "bg-yellow-500/10 text-yellow-600" - : "bg-muted text-muted-foreground", - )} - > - {formatRelevance(citation.relevance)} match - - )} -
- - {/* Excerpt preview */} - {isLoading ? ( - - ) : excerptText ? ( -

- "{truncateExcerpt(excerptText)}" -

- ) : ( -

- No excerpt available -

- )} - - {/* Click hint */} -

- Click to jump to source -

-
-
-
- ); -} - -export default CitationPreview; diff --git a/apps/web/src/modules/pdf/components/citation.tsx b/apps/web/src/modules/pdf/components/citation.tsx deleted file mode 100644 index d40275e..0000000 --- a/apps/web/src/modules/pdf/components/citation.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { cn } from "@turbostarter/ui"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { usePdfViewer } from "../context/pdf-viewer-context"; - -import type { Citation } from "@turbostarter/ai/pdf/types"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface CitationProps { - /** Citation data from parsed AI response */ - citation: Citation; - /** Optional className for styling overrides */ - className?: string; -} - -// ============================================================================ -// Citation Component -// ============================================================================ - -/** - * Clickable inline citation component displayed as [1], [2], etc. - * Clicking navigates to the cited page and highlights the source. - * - * @example - * ```tsx - * - * // Renders: [1] - clickable, navigates to page 5 - * ``` - */ -export function Citation({ citation, className }: CitationProps) { - const { navigateTo, activeHighlight, clearHighlight } = usePdfViewer(); - - // Check if this citation is currently active - const isActive = activeHighlight === citation.embeddingId; - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Toggle behavior: if already active, deactivate; otherwise activate - if (isActive) { - clearHighlight(); - } else { - navigateTo({ - page: citation.pageNumber, - embeddingId: citation.embeddingId, - animate: true, - }); - } - }; - - return ( - - ); -} - -export default Citation; diff --git a/apps/web/src/modules/pdf/components/index.ts b/apps/web/src/modules/pdf/components/index.ts deleted file mode 100644 index bd6f35a..0000000 --- a/apps/web/src/modules/pdf/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// PDF Components - -// Navigation -export { NavigationControls } from "./navigation-controls"; - -// Citations -export { Citation, type CitationProps } from "./citation"; -export { CitationPreview, type CitationPreviewProps } from "./citation-preview"; diff --git a/apps/web/src/modules/pdf/components/navigation-controls.tsx b/apps/web/src/modules/pdf/components/navigation-controls.tsx deleted file mode 100644 index c333ca1..0000000 --- a/apps/web/src/modules/pdf/components/navigation-controls.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { usePdfNavigation } from "../hooks/use-pdf-navigation"; - -/** - * Back/Forward navigation controls for PDF viewer. - * Compact toolbar-style buttons for history navigation. - */ -export function NavigationControls() { - const { goBack, goForward, canGoBack, canGoForward } = usePdfNavigation(); - - return ( -
- - -
- ); -} diff --git a/apps/web/src/modules/pdf/components/recent-chats.tsx b/apps/web/src/modules/pdf/components/recent-chats.tsx deleted file mode 100644 index 25b8bcf..0000000 --- a/apps/web/src/modules/pdf/components/recent-chats.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { toast } from "sonner"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; - -import { pathsConfig } from "~/config/paths"; -import { authClient } from "~/lib/auth/client"; -import { TurboLink } from "~/modules/common/turbo-link"; - -import { pdf } from "../lib/api"; - -import type { Chat } from "@turbostarter/ai/chat/types"; - -dayjs.extend(relativeTime); - -const ChatCard = ({ chat }: { chat: Chat }) => { - const { data: session } = authClient.useSession(); - const userId = session?.user.id ?? ""; - const queryClient = useQueryClient(); - - const { mutate: deleteChat, isPending } = useMutation({ - ...pdf.mutations.chats.delete, - onMutate: async (data) => { - await queryClient.cancelQueries({ - queryKey: pdf.queries.chats.user.getAll(userId).queryKey, - }); - const previousChats = queryClient.getQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - ); - queryClient.setQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - (old: Chat[]) => old.filter((c) => c.id !== data.id), - ); - return { previousChats }; - }, - onError: (error, _, context) => { - toast.error(error.message); - queryClient.setQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - context?.previousChats, - ); - }, - onSettled: async () => { - await queryClient.invalidateQueries(pdf.queries.chats.user.getAll(userId)); - }, - }); - - return ( -
- -
-
- -
- -
-
-

{chat.name}

-

- {dayjs(chat.createdAt).fromNow()} -

-
-
- ); -}; - -export const RecentChats = () => { - const { t } = useTranslation("ai"); - const { data: session } = authClient.useSession(); - const userChats = useQuery({ - ...pdf.queries.chats.user.getAll(session?.user.id ?? ""), - enabled: !!session?.user.id, - }); - - if (!session?.user.id) { - return null; - } - - if (userChats.isLoading) { - return ( -
-
- - -
-
- - - - -
-
- ); - } - - const recentChats = userChats.data?.slice(0, 6) ?? []; - - if (recentChats.length === 0) { - return null; - } - - return ( -
-
- - {t("pdf.recent")} -
-
- {recentChats.map((chat) => ( - - ))} -
-
- ); -}; diff --git a/apps/web/src/modules/pdf/components/text-selection-action.tsx b/apps/web/src/modules/pdf/components/text-selection-action.tsx deleted file mode 100644 index 9ff2ef2..0000000 --- a/apps/web/src/modules/pdf/components/text-selection-action.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { cn } from "@turbostarter/ui"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; - -// ============================================================================ -// Types -// ============================================================================ - -interface TextSelectionActionProps { - onAskAbout: (text: string) => void; - disabled?: boolean; -} - -interface SelectionState { - text: string; - x: number; - y: number; -} - -// ============================================================================ -// Text Selection Action -// ============================================================================ - -/** - * Floating action button that appears when text is selected in the PDF viewer. - * Clicking "Ask about this" sends the selected text to the chat composer. - * - * Uses mouseup event instead of selectionchange to avoid excessive re-renders. - */ -export function TextSelectionAction({ - onAskAbout, - disabled, -}: TextSelectionActionProps) { - const { t } = useTranslation("ai"); - const [selection, setSelection] = useState(null); - const buttonRef = useRef(null); - - // Handle mouse up to check for selection - const handleMouseUp = useCallback(() => { - // Small delay to ensure selection is complete - requestAnimationFrame(() => { - const sel = window.getSelection(); - const text = sel?.toString().trim(); - - if (text && text.length > 3) { - // Only show for meaningful selections (more than 3 chars) - const range = sel?.getRangeAt(0); - const rect = range?.getBoundingClientRect(); - - if (rect && rect.width > 0) { - setSelection({ - text, - x: rect.left + rect.width / 2, - y: rect.top - 10, - }); - } - } - }); - }, []); - - // Clear selection when clicking outside - const handleMouseDown = useCallback((e: MouseEvent) => { - const target = e.target as HTMLElement; - // Don't clear if clicking on the action button itself - if (buttonRef.current?.contains(target)) return; - - setSelection(null); - }, []); - - // Listen for mouse events - useEffect(() => { - document.addEventListener("mouseup", handleMouseUp); - document.addEventListener("mousedown", handleMouseDown); - - return () => { - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("mousedown", handleMouseDown); - }; - }, [handleMouseUp, handleMouseDown]); - - const handleAskAbout = useCallback(() => { - if (selection?.text) { - onAskAbout(selection.text); - // Clear selection after asking - window.getSelection()?.removeAllRanges(); - setSelection(null); - } - }, [selection, onAskAbout]); - - if (!selection) { - return null; - } - - return ( -
- -
- ); -} - -export default TextSelectionAction; diff --git a/apps/web/src/modules/pdf/composer/index.tsx b/apps/web/src/modules/pdf/composer/index.tsx deleted file mode 100644 index e815143..0000000 --- a/apps/web/src/modules/pdf/composer/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { Composer } from "~/modules/common/ai/composer"; - -import { useComposer } from "./use-composer"; - -import type { PdfMessage } from "@turbostarter/ai/pdf/types"; - -interface ChatComposerProps { - readonly id?: string; - readonly initialMessages?: PdfMessage[]; -} - -export const ChatComposer = ({ - id, - initialMessages, -}: ChatComposerProps = {}) => { - const { t } = useTranslation(["ai", "common"]); - const { status, input, setInput, sendMessage, stop } = useComposer({ - id, - initialMessages, - }); - - return ( - { - e.preventDefault(); - void sendMessage({ - text: input, - }); - setInput(""); - }} - > - - setInput(e.currentTarget.value)} - maxLength={5_000} - placeholder={t("pdf.composer.placeholder")} - className="pr-11 @[480px]/input:pr-11" - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - - if (!["submitted", "streaming"].includes(status)) { - void sendMessage({ - text: input, - }); - setInput(""); - } - } - }} - /> - - - - - ); -}; diff --git a/apps/web/src/modules/pdf/composer/use-composer.tsx b/apps/web/src/modules/pdf/composer/use-composer.tsx deleted file mode 100644 index fc88845..0000000 --- a/apps/web/src/modules/pdf/composer/use-composer.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useChat } from "@ai-sdk/react"; -import { Chat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; -import { useState } from "react"; - -import { getMessageTextContent } from "@turbostarter/ai"; -import { generateId } from "@turbostarter/shared/utils"; - -import { api } from "~/lib/api/client"; -import { useAIError } from "~/modules/common/hooks/use-ai-error"; -import { useCredits } from "~/modules/common/layout/credits"; - -import type { PdfMessage } from "@turbostarter/ai/pdf/types"; - -const chats = new Map>(); - -const getChatInstance = ({ - id, - ...options -}: ConstructorParameters>[0]) => { - if (!id || !chats.has(id)) { - const chat = new Chat({ - id, - ...options, - }); - - chats.set(id ?? chat.id, chat); - } - - const instance = chats.get(id ?? ""); - if (!instance) { - throw new Error(`Chat instance with id ${id} not found!`); - } - return instance; -}; - -interface UseComposerProps { - readonly id?: string; - readonly initialMessages?: PdfMessage[]; -} - -export const useComposer = ({ - id: passedId, - initialMessages, -}: UseComposerProps = {}) => { - const [input, setInput] = useState(""); - - const { onError } = useAIError(); - const { invalidate } = useCredits(); - const id = passedId ?? generateId(); - - const chat = getChatInstance({ - id, - transport: new DefaultChatTransport({ - api: api.ai.pdf.chats[":id"].messages - .$url({ - param: { - id, - }, - }) - .toString(), - prepareSendMessagesRequest: ({ messages }) => { - const lastMessage = messages.at(-1); - - return { - body: { - id: lastMessage?.id, - role: lastMessage?.role, - content: getMessageTextContent(lastMessage), - }, - }; - }, - }), - messages: initialMessages, - onFinish: () => { - void invalidate(); - }, - onError, - }); - - const result = useChat({ - chat, - }); - - return { - ...result, - input, - setInput, - }; -}; diff --git a/apps/web/src/modules/pdf/context/index.ts b/apps/web/src/modules/pdf/context/index.ts deleted file mode 100644 index 3d16bba..0000000 --- a/apps/web/src/modules/pdf/context/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - PdfViewerProvider, - usePdfViewer, - useCanGoBack, - useCanGoForward, -} from "./pdf-viewer-context"; diff --git a/apps/web/src/modules/pdf/context/pdf-viewer-context.tsx b/apps/web/src/modules/pdf/context/pdf-viewer-context.tsx deleted file mode 100644 index 8775122..0000000 --- a/apps/web/src/modules/pdf/context/pdf-viewer-context.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from "react"; - -import type { - NavigationEntry, - PdfViewerActions, - PdfViewerState, - PreciseCitation, - TextHighlight, -} from "@turbostarter/ai/pdf/types"; -import type { ReactNode } from "react"; - -// ============================================================================ -// Context Types -// ============================================================================ - -/** Navigation request to be consumed by PageSync */ -export interface PendingNavigation { - page: number; - embeddingId?: string; - animate?: boolean; -} - -interface PdfViewerContextValue extends PdfViewerState, PdfViewerActions { - /** Pending navigation request (consumed by PageSync, then cleared) */ - pendingNavigation: PendingNavigation | null; - /** Clear the pending navigation after it's been processed */ - clearPendingNavigation: () => void; - /** Text highlights from highlightText tool calls */ - textHighlights: TextHighlight[]; - /** Add a citation from highlightText tool call */ - addTextHighlight: (citation: PreciseCitation) => void; - /** Update highlight rects after text search resolves */ - updateTextHighlightRects: (id: string, rects: DOMRect[], found: boolean) => void; - /** Clear all text highlights (e.g., on new message) */ - clearTextHighlights: () => void; -} - -// ============================================================================ -// Context -// ============================================================================ - -const PdfViewerContext = createContext(null); - -// ============================================================================ -// Provider -// ============================================================================ - -interface PdfViewerProviderProps { - children: ReactNode; - /** Initial page to display */ - initialPage?: number; -} - -export function PdfViewerProvider({ - children, - initialPage = 1, -}: PdfViewerProviderProps) { - // State - const [currentPage, setCurrentPage] = useState(initialPage); - const [zoomLevel, _setZoomLevel] = useState(1); - const [scrollPosition, _setScrollPosition] = useState(0); - const [activeHighlight, setActiveHighlight] = useState(null); - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const [pendingNavigation, setPendingNavigation] = - useState(null); - const [textHighlights, setTextHighlights] = useState([]); - - // Actions - const navigateTo = useCallback( - (options: { page: number; embeddingId?: string; animate?: boolean }) => { - const { page, embeddingId, animate = true } = options; - - // Add to history - const entry: NavigationEntry = { - page, - embeddingId, - timestamp: Date.now(), - }; - - setHistory((prev) => { - // If we're in the middle of history, truncate forward entries - const newHistory = - historyIndex >= 0 ? prev.slice(0, historyIndex + 1) : prev; - return [...newHistory, entry]; - }); - setHistoryIndex((prev) => prev + 1); - - // Set highlight for HighlightLayer - setActiveHighlight(embeddingId ?? null); - - // Set pending navigation for PageSync to consume - // PageSync will call lector's jumpToPage and update currentPage - setPendingNavigation({ page, embeddingId, animate }); - }, - [historyIndex], - ); - - const clearPendingNavigation = useCallback(() => { - setPendingNavigation(null); - }, []); - - const goBack = useCallback(() => { - if (historyIndex <= 0) return; - - const prevIndex = historyIndex - 1; - const entry = history[prevIndex]; - if (!entry) return; - - setHistoryIndex(prevIndex); - setCurrentPage(entry.page); - setActiveHighlight(entry.embeddingId ?? null); - }, [history, historyIndex]); - - const goForward = useCallback(() => { - if (historyIndex >= history.length - 1) return; - - const nextIndex = historyIndex + 1; - const entry = history[nextIndex]; - if (!entry) return; - - setHistoryIndex(nextIndex); - setCurrentPage(entry.page); - setActiveHighlight(entry.embeddingId ?? null); - }, [history, historyIndex]); - - const clearHighlight = useCallback(() => { - setActiveHighlight(null); - }, []); - - // Text highlight actions (for highlightText tool) - const addTextHighlight = useCallback((citation: PreciseCitation) => { - setTextHighlights((prev) => [ - ...prev, - { - id: citation.citationId, - text: citation.text, - page: citation.page, - rects: [], // Populated when page renders - found: false, - }, - ]); - }, []); - - const updateTextHighlightRects = useCallback( - (id: string, rects: DOMRect[], found: boolean) => { - setTextHighlights((prev) => - prev.map((h) => (h.id === id ? { ...h, rects, found } : h)), - ); - }, - [], - ); - - const clearTextHighlights = useCallback(() => { - setTextHighlights([]); - }, []); - - // Memoized context value - const value = useMemo( - () => ({ - // State - currentPage, - zoomLevel, - scrollPosition, - activeHighlight, - history, - historyIndex, - pendingNavigation, - textHighlights, - // Actions - navigateTo, - goBack, - goForward, - clearHighlight, - clearPendingNavigation, - setCurrentPage, - addTextHighlight, - updateTextHighlightRects, - clearTextHighlights, - }), - [ - currentPage, - zoomLevel, - scrollPosition, - activeHighlight, - history, - historyIndex, - pendingNavigation, - textHighlights, - navigateTo, - goBack, - goForward, - clearHighlight, - clearPendingNavigation, - addTextHighlight, - updateTextHighlightRects, - clearTextHighlights, - ], - ); - - return ( - - {children} - - ); -} - -// ============================================================================ -// Hook -// ============================================================================ - -export function usePdfViewer(): PdfViewerContextValue { - const context = useContext(PdfViewerContext); - if (!context) { - throw new Error("usePdfViewer must be used within a PdfViewerProvider"); - } - return context; -} - -/** - * Check if we can go back in navigation history - */ -export function useCanGoBack(): boolean { - const { historyIndex } = usePdfViewer(); - return historyIndex > 0; -} - -/** - * Check if we can go forward in navigation history - */ -export function useCanGoForward(): boolean { - const { history, historyIndex } = usePdfViewer(); - return historyIndex < history.length - 1; -} diff --git a/apps/web/src/modules/pdf/history/actions.tsx b/apps/web/src/modules/pdf/history/actions.tsx deleted file mode 100644 index 281751d..0000000 --- a/apps/web/src/modules/pdf/history/actions.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useTranslation } from "@turbostarter/i18n"; -import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; - -import { pathsConfig } from "~/config/paths"; -import { TurboLink } from "~/modules/common/turbo-link"; - -interface ChatActionsProps { - onSelect: () => void; -} - -export const ChatActions = ({ onSelect }: ChatActionsProps) => { - const { t } = useTranslation(["common", "ai"]); - - return ( - - - - - {t("pdf.new")} - - - - ); -}; diff --git a/apps/web/src/modules/pdf/history/index.tsx b/apps/web/src/modules/pdf/history/index.tsx deleted file mode 100644 index 6250aa9..0000000 --- a/apps/web/src/modules/pdf/history/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client"; - -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import relativeTime from "dayjs/plugin/relativeTime"; -import { useState, useEffect } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { - CommandDialog, - CommandEmpty, - CommandInput, - CommandList, -} from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { ChatActions } from "./actions"; -import { ChatHistoryList } from "./list"; - -dayjs.extend(duration); -dayjs.extend(relativeTime); - -interface CommandMenuProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => { - const { t } = useTranslation("ai"); - - return ( - - - - {t("pdf.command.empty")} - onOpenChange(false)} /> - onOpenChange(false)} /> - - - ); -}; - -export const ChatHistory = () => { - const { t } = useTranslation("common"); - const [isOpen, setIsOpen] = useState(false); - - useEffect(() => { - const down = (e: KeyboardEvent) => { - if (e.key === "k" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setIsOpen((open) => !open); - } - }; - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, []); - - return ( - <> - - - - - - - {t("history")} - - {/* eslint-disable-next-line i18next/no-literal-string */} - K - - - - - - - - ); -}; diff --git a/apps/web/src/modules/pdf/history/list/index.tsx b/apps/web/src/modules/pdf/history/list/index.tsx deleted file mode 100644 index c403e59..0000000 --- a/apps/web/src/modules/pdf/history/list/index.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { useTranslation } from "@turbostarter/i18n"; -import { useDateGroups } from "@turbostarter/shared/hooks"; -import { CommandGroup } from "@turbostarter/ui-web/command"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; - -import { authClient } from "~/lib/auth/client"; - -import { pdf } from "../../lib/api"; - -import { ChatHistoryListItem } from "./item"; - -interface ChatHistoryListProps { - onSelect: () => void; -} - -export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => { - const { t } = useTranslation("common"); - const { data: session } = authClient.useSession(); - const userChats = useQuery( - pdf.queries.chats.user.getAll(session?.user.id ?? ""), - ); - - const groups = useDateGroups(userChats.data ?? []); - - if (userChats.isLoading) { - return ( - - - - - - ); - } - - return ( - <> - {groups.map( - (group) => - group.items.length > 0 && ( - - {group.items.map((chat) => ( - - ))} - - ), - )} - - ); -}; diff --git a/apps/web/src/modules/pdf/history/list/item.tsx b/apps/web/src/modules/pdf/history/list/item.tsx deleted file mode 100644 index 256205d..0000000 --- a/apps/web/src/modules/pdf/history/list/item.tsx +++ /dev/null @@ -1,166 +0,0 @@ -"use client"; - -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import dayjs from "dayjs"; -import { usePathname, useRouter } from "next/navigation"; -import { toast } from "sonner"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Badge } from "@turbostarter/ui-web/badge"; -import { Button } from "@turbostarter/ui-web/button"; -import { CommandItem } from "@turbostarter/ui-web/command"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { pathsConfig } from "~/config/paths"; -import { authClient } from "~/lib/auth/client"; -import { TurboLink } from "~/modules/common/turbo-link"; -import { pdf } from "~/modules/pdf/lib/api"; - -import type { Chat } from "@turbostarter/ai/chat/types"; - -interface ChatHistoryListItemProps { - readonly chat: Chat; - readonly onSelect: () => void; -} - -export const ChatHistoryListItem = ({ - chat, - onSelect, -}: ChatHistoryListItemProps) => { - const { t } = useTranslation("common"); - - const router = useRouter(); - const pathname = usePathname(); - - return ( - { - router.push(pathsConfig.apps.pdf.chat(chat.id)); - onSelect(); - }} - className="group" - > -
- - - {chat.name} - {pathname.includes(chat.id) && ( - {t("current")} - )} - - -
-
- ); -}; - -const Controls = ({ chat }: { chat: Chat }) => { - const { data: session } = authClient.useSession(); - const userId = session?.user.id ?? ""; - const { t } = useTranslation("common"); - - const router = useRouter(); - const pathname = usePathname(); - - const queryClient = useQueryClient(); - const { mutate } = useMutation({ - ...pdf.mutations.chats.delete, - onMutate: async (data) => { - await queryClient.cancelQueries({ - queryKey: pdf.queries.chats.user.getAll(userId).queryKey, - }); - - const previousChats = queryClient.getQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - ); - - queryClient.setQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - (old: Chat[]) => old.filter((chat) => chat.id !== data.id), - ); - - if (pathname.includes(chat.id)) { - router.push(pathsConfig.apps.pdf.index); - } - - return { previousChats }; - }, - onError: (error, _, context) => { - toast.error(error.message); - queryClient.setQueryData( - pdf.queries.chats.user.getAll(userId).queryKey, - context?.previousChats, - ); - }, - onSettled: async () => { - await queryClient.invalidateQueries( - pdf.queries.chats.user.getAll(userId), - ); - }, - }); - - return ( - <> - - {dayjs(chat.createdAt).fromNow()} - - -
- - - - - - -

{t("newTab")}

-
-
-
- - - - - - - -

{t("delete")}

-
-
-
-
- - ); -}; diff --git a/apps/web/src/modules/pdf/hooks/index.ts b/apps/web/src/modules/pdf/hooks/index.ts deleted file mode 100644 index 3bd888c..0000000 --- a/apps/web/src/modules/pdf/hooks/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// PDF Hooks - -export { usePdfViewer, useCanGoBack, useCanGoForward } from "../context"; -export { usePdfNavigation } from "./use-pdf-navigation"; -export { useEmbedding } from "./use-embedding"; -export { useCitationUnit } from "./use-citation-unit"; -export type { CitationUnitDetail, BoundingBox } from "./use-citation-unit"; diff --git a/apps/web/src/modules/pdf/hooks/use-citation-unit.ts b/apps/web/src/modules/pdf/hooks/use-citation-unit.ts deleted file mode 100644 index 22375b4..0000000 --- a/apps/web/src/modules/pdf/hooks/use-citation-unit.ts +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { api } from "~/lib/api/client"; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Bounding box for pixel-perfect highlighting - */ -export interface BoundingBox { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Citation unit with precise location for highlighting (WF-0028) - */ -export interface CitationUnitDetail { - id: string; - content: string; - pageNumber: number; - paragraphIndex: number; - charStart: number; - charEnd: number; - bbox: BoundingBox | null; - sectionTitle: string | null; - unitType: string; -} - -// ============================================================================ -// Hook -// ============================================================================ - -/** - * Fetch citation unit details by ID for bounding box-based highlighting - * - * Falls back to legacy embedding endpoint if citation unit not found - */ -export function useCitationUnit(unitId: string | null) { - return useQuery({ - queryKey: ["pdf", "citation-unit", unitId], - queryFn: async (): Promise => { - if (!unitId) return null; - - // Try citation unit endpoint first (WF-0028 dual-resolution) - const response = await api.ai.pdf.search["citation-units"].single[":id"].$get({ - param: { id: unitId }, - }); - - if (response.ok) { - const result = await response.json(); - return (result as { data: CitationUnitDetail }).data; - } - - // If not found in citation units, this might be a legacy embedding ID - // Return null - the highlight layer will fall back to word overlap - if (response.status === 404) { - return null; - } - - throw new Error("Failed to fetch citation unit"); - }, - enabled: Boolean(unitId), - staleTime: Infinity, // Citation units don't change - gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes - }); -} diff --git a/apps/web/src/modules/pdf/hooks/use-embedding.ts b/apps/web/src/modules/pdf/hooks/use-embedding.ts deleted file mode 100644 index eaaa314..0000000 --- a/apps/web/src/modules/pdf/hooks/use-embedding.ts +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; - -import { api } from "~/lib/api/client"; - -// ============================================================================ -// Types -// ============================================================================ - -export interface EmbeddingDetail { - id: string; - content: string; - pageNumber: number; - charStart?: number; - charEnd?: number; - sectionTitle?: string; -} - -// ============================================================================ -// Hook -// ============================================================================ - -/** - * Fetch embedding details by ID for citation highlighting - */ -export function useEmbedding(embeddingId: string | null) { - return useQuery({ - queryKey: ["pdf", "embedding", embeddingId], - queryFn: async (): Promise => { - if (!embeddingId) return null; - - const response = await api.ai.pdf.embeddings[":id"].$get({ - param: { id: embeddingId }, - }); - - if (!response.ok) { - if (response.status === 404) return null; - throw new Error("Failed to fetch embedding"); - } - - return response.json() as Promise; - }, - enabled: Boolean(embeddingId), - staleTime: Infinity, // Embeddings don't change - gcTime: 1000 * 60 * 30, // Keep in cache for 30 minutes - }); -} diff --git a/apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts b/apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts deleted file mode 100644 index 1d94292..0000000 --- a/apps/web/src/modules/pdf/hooks/use-pdf-navigation.ts +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { useCanGoBack, useCanGoForward, usePdfViewer } from "../context"; - -/** - * Convenience hook for PDF navigation controls. - * Combines navigation state and actions in one place. - */ -export function usePdfNavigation() { - const { goBack, goForward, navigateTo, history, historyIndex } = - usePdfViewer(); - const canGoBack = useCanGoBack(); - const canGoForward = useCanGoForward(); - - return { - // Actions - goBack, - goForward, - navigateTo, - // State - canGoBack, - canGoForward, - historyLength: history.length, - currentIndex: historyIndex, - }; -} diff --git a/apps/web/src/modules/pdf/index.ts b/apps/web/src/modules/pdf/index.ts deleted file mode 100644 index e866e1f..0000000 --- a/apps/web/src/modules/pdf/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -// PDF Module -// Exports for external use - -// Context & Hooks -export { - PdfViewerProvider, - usePdfViewer, - useCanGoBack, - useCanGoForward, -} from "./context"; -export { usePdfNavigation } from "./hooks"; - -// Layout Components -export { PdfLayout } from "./layout/layout"; -export { PdfPreview } from "./layout/preview"; - -// Thread Components -export { Chat as PdfChat } from "./thread"; -export { ChatComposer as PdfComposer } from "./composer"; -export { CitationMarkdown } from "./thread/citation-markdown"; - -// Navigation & Citations -export { - NavigationControls, - Citation, - CitationPreview, - type CitationProps, - type CitationPreviewProps, -} from "./components"; diff --git a/apps/web/src/modules/pdf/layout/layout.tsx b/apps/web/src/modules/pdf/layout/layout.tsx deleted file mode 100644 index d50aaf9..0000000 --- a/apps/web/src/modules/pdf/layout/layout.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; - -import { useTranslation } from "@turbostarter/i18n"; -import { useBreakpoint } from "@turbostarter/ui-web"; -import { Button } from "@turbostarter/ui-web/button"; -import { Drawer, DrawerContent } from "@turbostarter/ui-web/drawer"; -import { Icons } from "@turbostarter/ui-web/icons"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@turbostarter/ui-web/resizable"; -import { Skeleton } from "@turbostarter/ui-web/skeleton"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@turbostarter/ui-web/tooltip"; - -import { Header } from "~/modules/common/layout/header"; -import { ThemeSwitcher } from "~/modules/common/theme"; - -import { PdfViewerProvider } from "../context"; -import { ChatHistory } from "../history"; -import { pdf } from "../lib/api"; - -import { PdfPreview } from "./preview"; - -const Trigger = ({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - const { t } = useTranslation("ai"); - - return ( - - - - - - - {t("pdf.preview.toggle")} - - - - ); -}; - -const MobileDocumentPreview = ({ - id, - open, - onOpenChange, -}: { - id: string; - open: boolean; - onOpenChange: (open: boolean) => void; -}) => { - return ( - - - - - - ); -}; - -const ProcessingIndicator = () => { - const { t } = useTranslation("ai"); - - return ( -
-
- - - {t("pdf.processing.indexing")} - -
-

- {t("pdf.processing.indexingDescription")} -

-
- ); -}; - -const Documents = ({ id }: { id: string }) => { - const { t } = useTranslation("ai"); - - const documents = useQuery(pdf.queries.chats.documents.getAll(id)); - const document = documents.data?.[0]; - - // Poll for document processing status - const status = useQuery({ - ...pdf.queries.chats.documents.getStatus(document?.id ?? ""), - enabled: !!document?.id, - refetchInterval: (query) => { - const data = query.state.data; - // Stop polling once ready, failed, or error - if (!data || "error" in data) { - return false; - } - if (data.processingStatus === "ready" || data.processingStatus === "failed") { - return false; - } - return 2000; // Poll every 2 seconds while processing - }, - }); - - const url = useQuery({ - ...pdf.queries.chats.documents.getUrl(document?.path ?? ""), - enabled: !!document, - staleTime: 1000 * 60 * 60, - }); - - if (documents.isLoading || url.isLoading) { - return ; - } - - if (!url.data?.url) { - return ( -
- -

{t("pdf.preview.noDocuments")}

-
- ); - } - - // Check if still processing (not ready, not failed, not error) - const statusData = status.data && !("error" in status.data) ? status.data : null; - const isProcessing = statusData?.processingStatus !== "ready" && - statusData?.processingStatus !== "failed"; - - return ( -
- - {isProcessing && } -
- ); -}; - -export const PdfLayout = ({ - children, - id, -}: { - children: React.ReactNode; - id: string; -}) => { - const [open, setOpen] = useState(true); - const isDesktop = useBreakpoint("lg"); - - if (isDesktop) { - return ( - - - -
-
-
- - - -
-
- {children} -
-
- - {open && ( - <> - - - - - - )} -
-
- ); - } - - return ( - -
-
-
- - - -
-
- {children} - -
-
- ); -}; diff --git a/apps/web/src/modules/pdf/layout/preview/document-menu.tsx b/apps/web/src/modules/pdf/layout/preview/document-menu.tsx deleted file mode 100644 index ffee8c6..0000000 --- a/apps/web/src/modules/pdf/layout/preview/document-menu.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { usePdf } from "@anaralabs/lector"; -import { useState } from "react"; -import { toast } from "sonner"; - -import { useTranslation } from "@turbostarter/i18n"; -import { Button } from "@turbostarter/ui-web/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@turbostarter/ui-web/dropdown-menu"; -import { Icons } from "@turbostarter/ui-web/icons"; - -interface DocumentMenuProps { - readonly documentUrl: string; -} - -export const DocumentMenu = ({ documentUrl }: DocumentMenuProps) => { - const { t } = useTranslation("common"); - const [isDownloading, setIsDownloading] = useState(false); - const pdfDocumentProxy = usePdf((state) => state.pdfDocumentProxy); - - const handleDownload = async () => { - if (isDownloading) return; - - try { - setIsDownloading(true); - - const pdfData = await pdfDocumentProxy.getData(); - const blob = new Blob([pdfData as BlobPart], { type: "application/pdf" }); - - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - - const filename = documentUrl.split("/").pop() ?? "document.pdf"; - link.download = filename; - - document.body.appendChild(link); - link.click(); - - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch (error) { - console.error(error); - toast.error(t("error.general")); - } finally { - setIsDownloading(false); - } - }; - - const handleOpenClick = () => { - window.open(documentUrl, "_blank"); - }; - - return ( - - - - - - - - {isDownloading ? t("downloading") : t("download")} - - - {t("open")} - - - - ); -}; - -export default DocumentMenu; diff --git a/apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx b/apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx deleted file mode 100644 index 6477233..0000000 --- a/apps/web/src/modules/pdf/layout/preview/highlight-layer.tsx +++ /dev/null @@ -1,551 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-condition */ -"use client"; - -import { memo, useCallback, useEffect, useRef } from "react"; - -import { usePdfViewer } from "../../context"; -import { useEmbedding } from "../../hooks"; -import { useCitationUnit } from "../../hooks/use-citation-unit"; - -import type { BoundingBox } from "../../hooks/use-citation-unit"; - -// ============================================================================ -// Constants -// ============================================================================ - -/** Duration in ms before auto-clearing the highlight */ -const HIGHLIGHT_DURATION_MS = 5000; - -/** Minimum word match percentage to consider a span relevant (legacy fallback) */ -const MIN_MATCH_PERCENTAGE = 0.25; - -/** CSS class for primary highlight (exact citation - violet) */ -const HIGHLIGHT_PRIMARY_CLASS = "pdf-citation-primary"; - -/** CSS class for secondary highlight (context - yellow) */ -const HIGHLIGHT_SECONDARY_CLASS = "pdf-citation-secondary"; - -/** Legacy class name for backward compatibility */ -const HIGHLIGHT_CLASS = "pdf-citation-highlight"; - -/** Data attribute to mark highlighted spans */ -const HIGHLIGHT_ATTR = "data-citation-highlight"; - -// ============================================================================ -// Styles -// ============================================================================ - -/** Injected styles for text span highlighting (two-level: primary + secondary) */ -const HIGHLIGHT_STYLES = ` -/* Secondary highlight - yellow/amber for context */ -.${HIGHLIGHT_SECONDARY_CLASS} { - background-color: rgba(250, 204, 21, 0.25) !important; - border-radius: 2px; - transition: background-color 300ms ease-in-out; -} - -/* Primary highlight - violet for exact citation (overrides secondary) */ -.${HIGHLIGHT_PRIMARY_CLASS} { - background-color: rgba(139, 92, 246, 0.4) !important; - border-radius: 2px; - box-shadow: 0 0 6px rgba(139, 92, 246, 0.5); - transition: background-color 300ms ease-in-out; -} - -/* Legacy highlight class (backward compatibility) */ -.${HIGHLIGHT_CLASS} { - background-color: rgba(250, 204, 21, 0.4) !important; - border-radius: 2px; - box-shadow: 0 0 4px rgba(250, 204, 21, 0.6); - transition: background-color 300ms ease-in-out; -} -`; - -// ============================================================================ -// Utilities - Legacy Word Overlap Matching -// ============================================================================ - -/** - * Inject highlight styles into document head (once) - */ -function ensureStylesInjected(): void { - if (typeof document === "undefined") return; - if (document.getElementById("pdf-highlight-styles")) return; - - const style = document.createElement("style"); - style.id = "pdf-highlight-styles"; - style.textContent = HIGHLIGHT_STYLES; - document.head.appendChild(style); -} - -/** - * Normalize text for comparison - removes extra whitespace, lowercases - */ -function normalizeText(text: string): string { - return text.toLowerCase().replace(/\s+/g, " ").trim(); -} - -/** - * Get significant words from text (words with 3+ characters) - */ -function getSignificantWords(text: string): Set { - const normalized = normalizeText(text); - const words = normalized.split(/\s+/).filter((w) => w.length >= 3); - return new Set(words); -} - -/** - * Calculate word overlap percentage between two texts - */ -function calculateWordOverlap(text1: string, text2: string): number { - const words1 = getSignificantWords(text1); - const words2 = getSignificantWords(text2); - - if (words1.size === 0 || words2.size === 0) return 0; - - let matchCount = 0; - for (const word of words1) { - if (words2.has(word)) matchCount++; - } - - return matchCount / Math.min(words1.size, words2.size); -} - -/** - * Find text layer spans that match the embedding content and apply highlights - */ -function applyHighlightsToSpans( - container: Element, - embeddingContent: string, -): number { - // Find the TextLayer - it has class "textLayer" from pdfjs - const textLayers = container.querySelectorAll(".textLayer"); - if (textLayers.length === 0) { - console.debug("[HighlightLayer] No TextLayer found"); - return 0; - } - - let highlightCount = 0; - - // Check each text layer - for (const textLayer of textLayers) { - const spans = textLayer.querySelectorAll("span"); - - // For each span, check if it contains significant words from the embedding - for (const span of spans) { - const spanText = span.textContent ?? ""; - if (spanText.trim().length < 3) continue; - - const overlap = calculateWordOverlap(spanText, embeddingContent); - if (overlap >= MIN_MATCH_PERCENTAGE) { - span.classList.add(HIGHLIGHT_CLASS); - span.setAttribute(HIGHLIGHT_ATTR, "true"); - highlightCount++; - } - } - } - - // If no individual spans match, try grouping consecutive spans - if (highlightCount === 0) { - for (const textLayer of textLayers) { - const spans = Array.from(textLayer.querySelectorAll("span")); - const combinedText = spans.map((s) => s.textContent ?? "").join(" "); - - // Check if the combined text contains significant content from embedding - const overlap = calculateWordOverlap(combinedText, embeddingContent); - if (overlap >= MIN_MATCH_PERCENTAGE) { - // Find contiguous groups that match - for (let i = 0; i < spans.length; i++) { - let groupText = ""; - - for (let j = i; j < Math.min(i + 10, spans.length); j++) { - groupText += " " + (spans[j]?.textContent ?? ""); - - const groupOverlap = calculateWordOverlap( - groupText, - embeddingContent, - ); - if (groupOverlap >= MIN_MATCH_PERCENTAGE) { - // Highlight all spans in this group - for (let k = i; k <= j; k++) { - const span = spans[k]; - if (span) { - span.classList.add(HIGHLIGHT_CLASS); - span.setAttribute(HIGHLIGHT_ATTR, "true"); - highlightCount++; - } - } - break; - } - } - - if (highlightCount > 0) break; - } - } - } - } - - console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans (legacy)`); - return highlightCount; -} - -/** - * Remove all highlights from the document (clears all highlight classes) - */ -function clearAllHighlights(container: Element | null): void { - if (!container) return; - - const highlighted = container.querySelectorAll(`[${HIGHLIGHT_ATTR}]`); - for (const el of highlighted) { - el.classList.remove(HIGHLIGHT_PRIMARY_CLASS); - el.classList.remove(HIGHLIGHT_SECONDARY_CLASS); - el.classList.remove(HIGHLIGHT_CLASS); - el.removeAttribute(HIGHLIGHT_ATTR); - } -} - -// ============================================================================ -// Utilities - Bounding Box to Text Span Highlighting -// ============================================================================ - -/** - * Parse percentage value from CSS style string (e.g., "left: 31.1%" -> 31.1) - */ -function parsePercentage(style: string, property: string): number | null { - const regex = new RegExp(`${property}:\\s*([\\d.]+)%`); - const match = style.match(regex); - return match?.[1] ? parseFloat(match[1]) : null; -} - -/** Margin settings for highlight levels */ -interface HighlightMargins { - horizontal: number; - vertical: number; -} - -/** Tight margins for primary highlight (exact citation) */ -const PRIMARY_MARGINS: HighlightMargins = { horizontal: 1, vertical: 0.5 }; - -/** Wider margins for secondary highlight (context) */ -const SECONDARY_MARGINS: HighlightMargins = { horizontal: 5, vertical: 3 }; - -/** - * Check if a span's position overlaps with the bbox region - * Both bbox (0-1 normalized) and span positions (0-100 percentage) need alignment - */ -function spanOverlapsBbox( - span: HTMLElement, - bbox: BoundingBox, - margins: HighlightMargins = SECONDARY_MARGINS, -): boolean { - const style = span.getAttribute("style") ?? ""; - - // Parse span position from inline style (percentage-based) - const spanLeft = parsePercentage(style, "left"); - const spanTop = parsePercentage(style, "top"); - - if (spanLeft === null || spanTop === null) { - return false; - } - - // Convert bbox normalized coords (0-1) to percentage (0-100) for comparison - const bboxLeft = bbox.x * 100; - const bboxTop = bbox.y * 100; - const bboxRight = (bbox.x + bbox.width) * 100; - const bboxBottom = (bbox.y + bbox.height) * 100; - - const spanInHorizontalRange = spanLeft >= (bboxLeft - margins.horizontal) && - spanLeft <= (bboxRight + margins.horizontal); - const spanInVerticalRange = spanTop >= (bboxTop - margins.vertical) && - spanTop <= (bboxBottom + margins.vertical); - - return spanInHorizontalRange && spanInVerticalRange; -} - -/** Highlight level for two-tier highlighting */ -type HighlightLevel = "primary" | "secondary"; - -/** - * Find and highlight text layer spans that fall within a bounding box - * Supports two-level highlighting: primary (exact citation) and secondary (context) - */ -function applyBboxHighlightsToSpans( - container: Element, - bbox: BoundingBox, - pageNumber: number, - level: HighlightLevel = "primary", -): number { - // Find the specific page's text layer - const pageElement = container.querySelector(`[data-page-number="${pageNumber}"]`); - const textLayer = pageElement?.querySelector(".textLayer") ?? - container.querySelector(".textLayer"); - - if (!textLayer) { - console.debug("[HighlightLayer] No TextLayer found for bbox highlight"); - return 0; - } - - const spans = textLayer.querySelectorAll("span"); - let highlightCount = 0; - - // Select margins and class based on level - const margins = level === "primary" ? PRIMARY_MARGINS : SECONDARY_MARGINS; - const highlightClass = level === "primary" ? HIGHLIGHT_PRIMARY_CLASS : HIGHLIGHT_SECONDARY_CLASS; - - for (const span of spans) { - const spanText = span.textContent ?? ""; - if (spanText.trim().length < 1) continue; - - if (spanOverlapsBbox(span as HTMLElement, bbox, margins)) { - span.classList.add(highlightClass); - span.setAttribute(HIGHLIGHT_ATTR, level); - highlightCount++; - } - } - - console.debug(`[HighlightLayer] Highlighted ${highlightCount} spans via bbox (${level})`); - return highlightCount; -} - -// ============================================================================ -// Main Component -// ============================================================================ - -/** - * HighlightLayer - Applies CSS highlights to PDF TextLayer spans based on citations - * - * All highlighting is done by applying CSS classes directly to the PDF.js TextLayer - * span elements, ensuring highlights scroll naturally with the document. - * - * Supports two highlight detection modes: - * 1. Bounding Box (WF-0028): Uses bbox coordinates to find spans within the region - * 2. Word Overlap (legacy): Falls back to text matching when bbox unavailable - * - * When `activeHighlight` is set in the PdfViewerContext, this component: - * 1. Fetches the citation unit data (or legacy embedding) - * 2. If bbox available: Finds TextLayer spans within the bounding box region - * 3. If no bbox: Searches TextLayer for spans with matching text content - * 4. Applies CSS classes to matching spans for highlighting - * - * The highlight auto-clears after 5 seconds. - */ -export const HighlightLayer = memo(function HighlightLayer() { - const { activeHighlight, clearHighlight } = usePdfViewer(); - - // Try citation unit first (WF-0028) - const { data: citationUnit, isLoading: citationLoading } = useCitationUnit(activeHighlight); - - // Fall back to legacy embedding if citation unit not found - const shouldFetchEmbedding = Boolean(activeHighlight) && !citationLoading && !citationUnit; - const { data: embedding } = useEmbedding(shouldFetchEmbedding ? activeHighlight : null); - - const containerRef = useRef(null); - const timeoutRef = useRef | null>(null); - const isApplyingHighlightsRef = useRef(false); - const lastHighlightIdRef = useRef(null); - - // Determine which mode to use - const hasBbox = citationUnit?.bbox != null; - const fallbackContent = citationUnit?.content ?? embedding?.content; - - // Debug logging for highlight mode selection - useEffect(() => { - if (activeHighlight) { - console.debug("[HighlightLayer] Active highlight:", activeHighlight, { - citationLoading, - hasCitationUnit: Boolean(citationUnit), - hasBbox, - hasEmbedding: Boolean(embedding), - fallbackContentPreview: fallbackContent?.slice(0, 50), - }); - } - }, [activeHighlight, citationLoading, citationUnit, hasBbox, embedding, fallbackContent]); - - // Ensure styles are injected (used by both bbox and legacy modes) - useEffect(() => { - ensureStylesInjected(); - }, []); - - // Apply bbox-based highlights (CSS on text spans within bounding box) - // Two-level highlighting: secondary (yellow, context) then primary (violet, exact citation) - // Note: Clearing is handled by the main effect, not here - const applyBboxHighlights = useCallback(() => { - const container = containerRef.current?.parentElement; - if (!container || !citationUnit?.bbox) { - return; - } - - // First: Apply secondary highlights (yellow) with wider margins for context - const secondaryCount = applyBboxHighlightsToSpans( - container, - citationUnit.bbox, - citationUnit.pageNumber, - "secondary", - ); - - // Second: Apply primary highlights (violet) with tight margins for exact citation - // This overlays secondary highlights on matching spans (CSS priority handles override) - const primaryCount = applyBboxHighlightsToSpans( - container, - citationUnit.bbox, - citationUnit.pageNumber, - "primary", - ); - - console.debug( - `[HighlightLayer] Two-level highlights: ${primaryCount} primary (violet), ${secondaryCount} secondary (yellow)`, - ); - }, [citationUnit]); - - // Apply legacy highlights to matching text (word overlap) - // Note: Clearing is handled by the main effect, not here - const applyLegacyHighlights = useCallback(() => { - const container = containerRef.current?.parentElement; - if (!container || !fallbackContent) { - console.debug("[HighlightLayer] Legacy mode: no container or content", { - hasContainer: Boolean(container), - contentPreview: fallbackContent?.slice(0, 100), - }); - return; - } - - console.debug("[HighlightLayer] Applying legacy highlights with content:", fallbackContent.slice(0, 100) + "..."); - - // Apply new highlights using word overlap matching - const count = applyHighlightsToSpans(container, fallbackContent); - if (count === 0) { - console.warn("[HighlightLayer] No spans matched for legacy highlight. TextLayers found:", container.querySelectorAll(".textLayer").length); - } - }, [fallbackContent]); - - // Apply highlights when data changes (unified for both modes) - useEffect(() => { - const container = containerRef.current?.parentElement; - - if (!activeHighlight) { - // Only clear if we previously had a highlight - if (lastHighlightIdRef.current !== null) { - clearAllHighlights(container ?? null); - lastHighlightIdRef.current = null; - } - return; - } - - // Choose highlight method based on available data - const applyHighlights = hasBbox ? applyBboxHighlights : applyLegacyHighlights; - - // Wait for condition to be ready - if (hasBbox && !citationUnit?.bbox) return; - if (!hasBbox && !fallbackContent) return; - - if (!container) return; - - // Check if this is a NEW highlight (different ID) - only then clear old highlights - const isNewHighlight = lastHighlightIdRef.current !== activeHighlight; - if (isNewHighlight) { - clearAllHighlights(container); - lastHighlightIdRef.current = activeHighlight; - } - - // Use ref-based flag to prevent MutationObserver re-triggering across effect runs - const safeApplyHighlights = () => { - if (isApplyingHighlightsRef.current) return; - isApplyingHighlightsRef.current = true; - try { - // Don't clear again - just apply (clearing already done above for new highlights) - applyHighlights(); - } finally { - // Reset flag after a brief delay to allow DOM to settle - setTimeout(() => { - isApplyingHighlightsRef.current = false; - }, 100); - } - }; - - // Give the TextLayer time to render after page navigation - const initialTimeout = setTimeout(safeApplyHighlights, 100); - - // Observe DOM changes in case TextLayer loads later (e.g., lazy loading pages) - // Only re-apply when NEW TextLayer content is added, not when we modify highlight classes - let debounceTimeout: ReturnType | null = null; - - const observer = new MutationObserver((mutations) => { - // Check if any mutation added new TextLayer spans (not just class changes) - const hasNewTextContent = mutations.some((mutation) => { - // Only care about added nodes - if (mutation.type !== "childList" || mutation.addedNodes.length === 0) { - return false; - } - // Check if added nodes contain TextLayer or span elements - return Array.from(mutation.addedNodes).some((node) => { - if (node.nodeType !== Node.ELEMENT_NODE) return false; - const el = node as Element; - return el.classList.contains("textLayer") || - el.querySelector(".textLayer") !== null || - (el.tagName === "SPAN" && el.closest(".textLayer")); - }); - }); - - if (!hasNewTextContent) return; - - // Debounce to handle multiple rapid mutations - if (debounceTimeout) clearTimeout(debounceTimeout); - debounceTimeout = setTimeout(safeApplyHighlights, 150); - }); - - observer.observe(container, { - childList: true, - subtree: true, - }); - - return () => { - clearTimeout(initialTimeout); - if (debounceTimeout) clearTimeout(debounceTimeout); - observer.disconnect(); - }; - }, [activeHighlight, hasBbox, citationUnit, fallbackContent, applyBboxHighlights, applyLegacyHighlights]); - - // Auto-clear highlight after duration - useEffect(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - - if (activeHighlight) { - timeoutRef.current = setTimeout(() => { - clearAllHighlights(containerRef.current?.parentElement ?? null); - clearHighlight(); - timeoutRef.current = null; - }, HIGHLIGHT_DURATION_MS); - } - - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }; - }, [activeHighlight, clearHighlight]); - - // Cleanup highlights on unmount - useEffect(() => { - const container = containerRef.current; - return () => { - clearAllHighlights(container?.parentElement ?? null); - }; - }, []); - - // This component no longer renders any visible elements - // All highlighting is done via CSS classes on TextLayer spans - return ( -