@aj-archipelago/cortex 1.3.32 → 1.3.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/helper-apps/cortex-autogen/OAI_CONFIG_LIST +1 -1
- package/lib/encodeCache.js +22 -10
- package/lib/pathwayTools.js +10 -3
- package/lib/requestExecutor.js +1 -1
- package/lib/util.js +136 -1
- package/package.json +2 -2
- package/pathways/system/entity/memory/sys_memory_manager.js +2 -1
- package/pathways/system/entity/sys_entity_continue.js +10 -2
- package/pathways/system/entity/sys_entity_start.js +12 -10
- package/pathways/system/entity/sys_router_tool.js +2 -2
- package/server/chunker.js +23 -3
- package/server/pathwayResolver.js +2 -5
- package/server/plugins/claude3VertexPlugin.js +2 -3
- package/server/plugins/cohereGeneratePlugin.js +1 -1
- package/server/plugins/gemini15ChatPlugin.js +1 -1
- package/server/plugins/geminiChatPlugin.js +1 -1
- package/server/plugins/localModelPlugin.js +1 -1
- package/server/plugins/modelPlugin.js +332 -77
- package/server/plugins/openAiChatPlugin.js +1 -1
- package/server/plugins/openAiCompletionPlugin.js +1 -1
- package/server/plugins/palmChatPlugin.js +1 -1
- package/server/plugins/palmCodeCompletionPlugin.js +1 -1
- package/server/plugins/palmCompletionPlugin.js +1 -1
- package/tests/chunkfunction.test.js +9 -6
- package/tests/claude3VertexPlugin.test.js +81 -3
- package/tests/data/largecontent.txt +1 -0
- package/tests/data/mixedcontent.txt +1 -0
- package/tests/encodeCache.test.js +47 -14
- package/tests/modelPlugin.test.js +21 -0
- package/tests/multimodal_conversion.test.js +1 -1
- package/tests/subscription.test.js +7 -1
- package/tests/tokenHandlingTests.test.js +587 -0
- package/tests/truncateMessages.test.js +404 -46
- package/tests/util.test.js +146 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"<CHAT_HISTORY>\n[{\"role\":\"user\",\"content\":[\"Release notes for this?\\n\\ndiff --git a/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml b/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml\\ndeleted file mode 100644\\nindex ed46bb3..0000000\\n--- a/.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml\\n+++ /dev/null\\n@@ -1,48 +0,0 @@\\n-name: Trigger auto deployment for labeeb-workers-main\\n-\\n-# When this action will be executed\\n-on:\\n- # Automatically trigger it when detected changes in repo\\n- push:\\n- branches: \\n- [ main ]\\n- paths:\\n- - '**'\\n- - '.github/workflows/labeeb-workers-main-AutoDeployTrigger-80f2a7e2-7b85-4d05-974c-f2699ea43214.yml'\\n-\\n- # Allow manual trigger \\n- workflow_dispatch: \\n-\\n-jobs:\\n- build-and-deploy-workers:\\n- runs-on: ubuntu-latest\\n- permissions: \\n- id-token: write #This is required for requesting the OIDC JWT Token\\n- contents: read #Required when GH token is used to authenticate with private repo\\n-\\n- steps:\\n- - name: Checkout to the branch\\n- uses: actions/checkout@v2\\n-\\n- - name: Azure Login\\n- uses: azure/login@v1\\n- with:\\n- client-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_CLIENT_ID }}\\n- tenant-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_TENANT_ID }}\\n- subscription-id: ${{ secrets.LABEEBWORKERSMAIN_AZURE_SUBSCRIPTION_ID }}\\n-\\n- - name: Build and push container image to registry\\n- uses: azure/container-apps-deploy-action@v2\\n- with:\\n- appSourcePath: ${{ github.workspace }}\\n- dockerFilePath: Dockerfile.worker\\n- registryUrl: archipelagoairegistry.azurecr.io\\n- registryUsername: ${{ secrets.LABEEBWORKERSMAIN_REGISTRY_USERNAME }}\\n- registryPassword: ${{ secrets.LABEEBWORKERSMAIN_REGISTRY_PASSWORD }}\\n- containerAppName: labeeb-workers-main\\n- resourceGroup: Archipelago-ML-Experimentation\\n- imageToBuild: archipelagoairegistry.azurecr.io/labeeb-workers-main:${{ github.sha }}\\n- _buildArgumentsKey_: |\\n- _buildArgumentsValues_\\n-\\n-\\ndiff --git a/.gitignore b/.gitignore\\nindex a71a12f..3138f16 100644\\n--- a/.gitignore\\n+++ b/.gitignore\\n@@ -30,3 +30,4 @@ public/app/\\n src/locales/\\n dump.rdb\\n *.code-workspace\\n+.cursorrules\\n\\\\\ndiff --git a/@/components/ui/alert-dialog.jsx b/@/components/ui/alert-dialog.jsx\\nnew file mode 100644\\nindex 0000000..1a0edf7\\n--- /dev/null\\n+++ b/@/components/ui/alert-dialog.jsx\\n@@ -0,0 +1,122 @@\\n+\\\"use client\\\";\\n+\\n+import * as React from \\\"react\\\";\\n+import * as AlertDialogPrimitive from \\\"@radix-ui/react-alert-dialog\\\";\\n+\\n+import { cn } from \\\"@/lib/utils\\\";\\n+import { buttonVariants } from \\\"@/components/ui/button\\\";\\n+\\n+const AlertDialog = AlertDialogPrimitive.Root;\\n+\\n+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;\\n+\\n+const AlertDialogPortal = AlertDialogPrimitive.Portal;\\n+\\n+const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Overlay\\n+ className={cn(\\n+ \\\"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ ref={ref}\\n+ />\\n+));\\n+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;\\n+\\n+const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPortal>\\n+ <AlertDialogOverlay />\\n+ <AlertDialogPrimitive.Content\\n+ ref={ref}\\n+ className={cn(\\n+ \\\"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-gray-200 bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dark:border-gray-800 dark:bg-gray-950\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+ </AlertDialogPortal>\\n+));\\n+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;\\n+\\n+const AlertDialogHeader = ({ className, ...props }) => (\\n+ <div\\n+ className={cn(\\n+ \\\"flex flex-col space-y-2 text-center sm:text-left\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+);\\n+AlertDialogHeader.displayName = \\\"AlertDialogHeader\\\";\\n+\\n+const AlertDialogFooter = ({ className, ...props }) => (\\n+ <div\\n+ className={cn(\\n+ \\\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+);\\n+AlertDialogFooter.displayName = \\\"AlertDialogFooter\\\";\\n+\\n+const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Title\\n+ ref={ref}\\n+ className={cn(\\\"text-lg font-semibold\\\", className)}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;\\n+\\n+const AlertDialogDescription = React.forwardRef(\\n+ ({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Description\\n+ ref={ref}\\n+ className={cn(\\n+ \\\"text-sm text-gray-500 dark:text-gray-400\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+ ),\\n+);\\n+AlertDialogDescription.displayName =\\n+ AlertDialogPrimitive.Description.displayName;\\n+\\n+const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Action\\n+ ref={ref}\\n+ className={cn(buttonVariants(), className)}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;\\n+\\n+const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (\\n+ <AlertDialogPrimitive.Cancel\\n+ ref={ref}\\n+ className={cn(\\n+ buttonVariants({ variant: \\\"outline\\\" }),\\n+ \\\"mt-2 sm:mt-0\\\",\\n+ className,\\n+ )}\\n+ {...props}\\n+ />\\n+));\\n+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;\\n+\\n+export {\\n+ AlertDialog,\\n+ AlertDialogPortal,\\n+ AlertDialogOverlay,\\n+ AlertDialogTrigger,\\n+ AlertDialogContent,\\n+ AlertDialogHeader,\\n+ AlertDialogFooter,\\n+ AlertDialogTitle,\\n+ AlertDialogDescription,\\n+ AlertDialogAction,\\n+ AlertDialogCancel,\\n+};\\ndiff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js\\nnew file mode 100644\\nindex 0000000..f053ebf\\n--- /dev/null\\n+++ b/__mocks__/styleMock.js\\n@@ -0,0 +1 @@\\n+module.exports = {};\\ndiff --git a/app.config/config/data/taxonomySets.js b/app.config/config/data/taxonomySets.js\\nindex 82bafa9..1ef9412 100644\\n--- a/app.config/config/data/taxonomySets.js\\n+++ b/app.config/config/data/taxonomySets.js\\n@@ -62,7 +62,7 @@ export async function initializeTaxonomies() {\\n // will include the same file with two paths:\\n // ./filename.json and <absolute-path>/filename.json\\n if (dedupedFileNames.includes(filenameOnly)) {\\n- return;\\n+ return null;\\n }\\n \\n const setName = filename.slice(2, -5); // Remove './' and '.json' from the file name\\ndiff --git a/app.config/config/index.js b/app.config/config/index.js\\nindex eb7facb..30c326d 100644\\n--- a/app.config/config/index.js\\n+++ b/app.config/config/index.js\\n@@ -14,7 +14,7 @@ const cortexURLs = {\\n // The entire Labeeb application can be configured here\\n // Note that all assets and locales are copied to the public/app and src/locales directories respectively\\n // by the prebuild.js script\\n-export default {\\n+const config = {\\n global: {\\n siteTitle: \\\"Labeeb\\\",\\n getLogo: (language) =>\\n@@ -77,3 +77,5 @@ export default {\\n provider: \\\"entra\\\",\\n },\\n };\\n+\\n+export default config;\\ndiff --git a/app.config/config/transcribe/TranscribeUrlConstants.js b/app.config/config/transcribe/TranscribeUrlConstants.js\\nindex db70436..e5e824c 100644\\n--- a/app.config/config/transcribe/TranscribeUrlConstants.js\\n+++ b/app.config/config/transcribe/TranscribeUrlConstants.js\\n@@ -1,3 +1,5 @@\\n+import { isYoutubeUrl } from \\\"../../../src/utils/urlUtils\\\";\\n+\\n export const AJE = \\\"665003303001\\\";\\n export const AJA = \\\"665001584001\\\";\\n export const getAxisUrl = (accountId, searchQuery) =>\\n@@ -9,6 +11,40 @@ export const fetchUrlSource = async (url) => {\\n );\\n if (!response.ok) {\\n const data = await response.json();\\n+ if (data.error === \\\"Unsupported YouTube channel\\\" && isYoutubeUrl(url)) {\\n+ // Convert YouTube URL to embed URL\\n+ const videoId = url.match(/(?:v=|\\\\/)([\\\\w-]{11})(?:\\\\?|$|&)/)?.[1];\\n+ const embedUrl = videoId\\n+ ? `https://www.youtube.com/embed/${videoId}`\\n+ : url;\\n+\\n+ // Fetch video title using oEmbed\\n+ let videoTitle = \\\"YouTube Video (External)\\\";\\n+ try {\\n+ const oembedResponse = await fetch(\\n+ `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,\\n+ );\\n+ if (oembedResponse.ok) {\\n+ const oembedData = await oembedResponse.json();\\n+ videoTitle = oembedData.title;\\n+ }\\n+ } catch (e) {\\n+ console.warn(\\\"Failed to fetch YouTube video title:\\\", e);\\n+ }\\n+\\n+ return {\\n+ results: [\\n+ {\\n+ name: videoTitle,\\n+ similarity: 1,\\n+ videoUrl: embedUrl,\\n+ url: url,\\n+ isYouTube: true,\\n+ fromExternalChannel: true,\\n+ },\\n+ ],\\n+ };\\n+ }\\n throw new Error(\\n formatErrorMessage(data.error) || \\\"Network response was not ok\\\",\\n );\\ndiff --git a/app.config/locales/ar.json b/app.config/locales/ar.json\\nindex 8550558..3966c3f 100644\\n--- a/app.config/locales/ar.json\\n+++ b/app.config/locales/ar.json\\n@@ -497,5 +497,31 @@\\n \\\"Download .txt\\\": \\\"تنزيل بتنسيق TXT\\\",\\n \\\"Taxonomy\\\": \\\"التصنيف\\\",\\n \\\"Transcript\\\": \\\"النص المنسوخ\\\",\\n- \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\"\\n+ \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\",\\n+ \\\"Processing media...\\\": \\\"جاري معالجة الوسائط...\\\",\\n+ \\\"Transcription type\\\": \\\"نوع التنسيق\\\",\\n+ \\\"Memory backup\\\": \\\"نسخة احتياطية للذاكرة\\\",\\n+ \\\"Download memory backup\\\": \\\"تنزيل نسخة احتياطية للذاكرة\\\",\\n+ \\\"Upload memory from backup\\\": \\\"تحميل الذاكرة من النسخة الاحتياطية\\\",\\n+ \\\"Failed to read the file. Please try again.\\\": \\\"فشل في قراءة الملف. يرجى المحاولة مرة أخرى.\\\",\\n+ \\\"Failed to parse memory file. Please ensure it is a valid JSON file with the correct memory structure.\\\": \\\"فشل في تحليل ملف الذاكرة. يرجى التأكد من أنه ملف JSON صالح بهيكل الذاكرة الصحيح.\\\",\\n+ \\\"Invalid memory file format\\\": \\\"تنسيق ملف الذاكرة غير صالح\\\",\\n+ \\\"Enable streaming responses\\\": \\\"تفعيل الاستجابات المنسية\\\",\\n+ \\\"{{from}} to {{to}}\\\": \\\"{{from}} إلى {{to}}\\\",\\n+ \\\"Video translation\\\": \\\"ترجمة الفيديو\\\",\\n+ \\\"In progress\\\": \\\"قيد التنفيذ\\\",\\n+ \\\"Completed\\\": \\\"منجز\\\",\\n+ \\\"Failed\\\": \\\"فشل\\\",\\n+ \\\"View all\\\": \\\"عرض الكل\\\",\\n+ \\\"No recent or active notifications\\\": \\\"لا يوجد إشعارات مفعلة\\\",\\n+ \\\"View history\\\": \\\"عرض التاريخ\\\",\\n+ \\\"All notifications\\\": \\\"جميع الإشعارات\\\",\\n+ \\\"Transcript not looking right?\\\": \\\"النص المنسوخ لا يبدو صحيحًا؟\\\",\\n+ \\\"Transcribe again using an alternate model\\\": \\\"تنسيق مرة أخرى باستخدام نموذج مختلف\\\",\\n+ \\\"Re-transcribing\\\": \\\"إعادة التنسيق\\\",\\n+ \\\"Add audio track\\\": \\\"إضافة صوت\\\",\\n+ \\\"Transcribing... This may take a few minutes.\\\": \\\"جاري التنسيق... قد يستغرق هذا بضع دقائق.\\\",\\n+ \\\"Auto-transcribing\\\": \\\"تنسيق تلقائي\\\",\\n+ \\\"Edit title\\\": \\\"تعديل العنوان\\\",\\n+ \\\"Delete chat\\\": \\\"حذف الدردشة\\\"\\n }\\ndiff --git a/app/api/azure-video-translate/route.js b/app/api/azure-video-translate/route.js\\nnew file mode 100644\\nindex 0000000..1bb3a97\\n--- /dev/null\\n+++ b/app/api/azure-video-translate/route.js\\n@@ -0,0 +1,107 @@\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { Queue } from \\\"bullmq\\\";\\n+import Redis from \\\"ioredis\\\";\\n+import { AZURE_VIDEO_TRANSLATE } from \\\"../../../src/graphql\\\";\\n+import { getClient } from \\\"../../../src/graphql\\\";\\n+import RequestProgress from \\\"../models/request-progress.mjs\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+const connection = new Redis(\\n+ process.env.REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n+ {\\n+ maxRetriesPerRequest: null,\\n+ },\\n+);\\n+\\n+const requestProgressQueue = new Queue(\\\"request-progress\\\", {\\n+ connection,\\n+});\\n+\\n+export async function POST(req) {\\n+ try {\\n+ const body = await req.json();\\n+ const { sourceLocale, targetLocale, targetLocaleLabel, url } = body;\\n+\\n+ console.log(\\\"Starting video translation request:\\\", {\\n+ sourceLocale,\\n+ targetLocale,\\n+ targetLocaleLabel,\\n+ url,\\n+ });\\n+\\n+ // Initial GraphQL query to start the translation\\n+ const { data } = await getClient().query({\\n+ query: AZURE_VIDEO_TRANSLATE,\\n+ variables: {\\n+ mode: \\\"uploadvideooraudiofileandcreatetranslation\\\",\\n+ sourcelocale: sourceLocale,\\n+ targetlocale: targetLocale,\\n+ sourcevideooraudiofilepath: url,\\n+ stream: true,\\n+ },\\n+ fetchPolicy: \\\"no-cache\\\",\\n+ });\\n+\\n+ const requestId = data.azure_video_translate.result;\\n+\\n+ console.log(\\\"Got requestId from Azure:\\\", requestId);\\n+\\n+ // Get current user\\n+ const user = await getCurrentUser();\\n+\\n+ // Create initial progress record\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ owner: user._id,\\n+ type: \\\"video-translate\\\",\\n+ status: \\\"in_progress\\\",\\n+ metadata: {\\n+ sourceLocale,\\n+ targetLocale,\\n+ url,\\n+ },\\n+ },\\n+ {\\n+ new: true,\\n+ upsert: true,\\n+ },\\n+ );\\n+\\n+ // Add job to queue\\n+ const job = await requestProgressQueue.add(\\n+ \\\"request-progress\\\",\\n+ {\\n+ requestId,\\n+ type: \\\"video-translate\\\",\\n+ userId: user._id,\\n+ metadata: {\\n+ sourceLocale,\\n+ targetLocale,\\n+ targetLocaleLabel,\\n+ url,\\n+ },\\n+ },\\n+ {\\n+ timeout: 5 * 60 * 1000,\\n+ removeOnComplete: {\\n+ age: 24 * 3600,\\n+ count: 1000,\\n+ },\\n+ removeOnFail: {\\n+ age: 24 * 3600,\\n+ },\\n+ },\\n+ );\\n+\\n+ console.log(\\\"Added job to queue:\\\", job.id);\\n+\\n+ return NextResponse.json({\\n+ requestId,\\n+ jobId: job.id,\\n+ });\\n+ } catch (error) {\\n+ console.error(\\\"Azure video translate error:\\\", error);\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/cancel-request/route.js b/app/api/cancel-request/route.js\\nnew file mode 100644\\nindex 0000000..ddcc3d5\\n--- /dev/null\\n+++ b/app/api/cancel-request/route.js\\n@@ -0,0 +1,53 @@\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { Queue } from \\\"bullmq\\\";\\n+import Redis from \\\"ioredis\\\";\\n+import RequestProgress from \\\"../models/request-progress.mjs\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+const connection = new Redis(\\n+ process.env.REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n+ {\\n+ maxRetriesPerRequest: null,\\n+ },\\n+);\\n+\\n+const requestProgressQueue = new Queue(\\\"request-progress\\\", { connection });\\n+\\n+export async function POST(req) {\\n+ try {\\n+ const { requestId } = await req.json();\\n+ const user = await getCurrentUser();\\n+\\n+ // Find the request and verify ownership\\n+ const request = await RequestProgress.findOne({\\n+ requestId,\\n+ owner: user._id,\\n+ });\\n+\\n+ if (!request) {\\n+ return NextResponse.json(\\n+ { error: \\\"Request not found\\\" },\\n+ { status: 404 },\\n+ );\\n+ }\\n+\\n+ // Get active jobs for this request\\n+ const jobs = await requestProgressQueue.getJobs([\\\"waiting\\\"]);\\n+ const job = jobs.find((job) => job.data.requestId === requestId);\\n+\\n+ if (job) {\\n+ await job.remove();\\n+ }\\n+\\n+ // Update request status\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ { status: \\\"cancelled\\\" },\\n+ );\\n+\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ console.error(\\\"Cancel request error:\\\", error);\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/chats/_lib.js b/app/api/chats/_lib.js\\nindex 0a8e842..7e52d78 100644\\n--- a/app/api/chats/_lib.js\\n+++ b/app/api/chats/_lib.js\\n@@ -20,6 +20,24 @@ export async function getRecentChatsOfCurrentUser() {\\n { _id: 1, title: 1, titleSetByUser: 1 },\\n );\\n \\n+ // For chats without a custom title, fetch the first message separately\\n+ // This approach avoids truncating the messages array in the main cache\\n+ for (const chat of recentChatsUnordered) {\\n+ if (!chat.title || chat.title === \\\"New Chat\\\" || chat.title === \\\"\\\") {\\n+ const chatWithFirstMessage = await Chat.findOne(\\n+ { _id: chat._id },\\n+ { messages: { $slice: 1 } },\\n+ );\\n+ if (\\n+ chatWithFirstMessage &&\\n+ chatWithFirstMessage.messages &&\\n+ chatWithFirstMessage.messages.length > 0\\n+ ) {\\n+ chat._doc.firstMessage = chatWithFirstMessage.messages[0];\\n+ }\\n+ }\\n+ }\\n+\\n const recentChatsMap = recentChatsUnordered.reduce((acc, chat) => {\\n acc[chat._id] = chat;\\n return acc;\\ndiff --git a/app/api/models/request-progress.mjs b/app/api/models/request-progress.mjs\\nnew file mode 100644\\nindex 0000000..820b682\\n--- /dev/null\\n+++ b/app/api/models/request-progress.mjs\\n@@ -0,0 +1,60 @@\\n+import mongoose from \\\"mongoose\\\";\\n+\\n+const requestProgressSchema = new mongoose.Schema(\\n+ {\\n+ requestId: {\\n+ type: String,\\n+ required: true,\\n+ unique: true,\\n+ },\\n+ owner: {\\n+ type: mongoose.Schema.Types.ObjectId,\\n+ ref: \\\"User\\\",\\n+ required: true,\\n+ },\\n+ progress: {\\n+ type: Number,\\n+ required: true,\\n+ default: 0,\\n+ },\\n+ data: mongoose.Schema.Types.Mixed,\\n+ statusText: String,\\n+ status: {\\n+ type: String,\\n+ enum: [\\n+ \\\"pending\\\",\\n+ \\\"in_progress\\\",\\n+ \\\"completed\\\",\\n+ \\\"failed\\\",\\n+ \\\"cancelled\\\",\\n+ ],\\n+ default: \\\"pending\\\",\\n+ },\\n+ error: String,\\n+ type: {\\n+ type: String,\\n+ required: true,\\n+ },\\n+ metadata: {\\n+ type: mongoose.Schema.Types.Mixed,\\n+ default: null,\\n+ },\\n+ dismissed: {\\n+ type: Boolean,\\n+ default: false,\\n+ },\\n+ },\\n+ {\\n+ timestamps: true,\\n+ },\\n+);\\n+\\n+requestProgressSchema.index({ requestId: 1 });\\n+requestProgressSchema.index({ createdAt: -1 });\\n+requestProgressSchema.index({ owner: 1 });\\n+\\n+const RequestProgress =\\n+ mongoose.models.RequestProgress ||\\n+ mongoose.model(\\\"RequestProgress\\\", requestProgressSchema);\\n+\\n+export default RequestProgress;\\ndiff --git a/app/api/models/user-state.js b/app/api/models/user-state.mjs\\nsimilarity index 100%\\nrename from app/api/models/user-state.js\\nrename to app/api/models/user-state.mjs\\ndiff --git a/app/api/models/user.mjs b/app/api/models/user.mjs\\nindex 7bea9fc..a72968f 100644\\n--- a/app/api/models/user.mjs\\n+++ b/app/api/models/user.mjs\\n@@ -42,6 +42,11 @@ const userSchema = new mongoose.Schema(\\n required: true,\\n default: \\\"OpenAI\\\",\\n },\\n+ streamingEnabled: {\\n+ type: Boolean,\\n+ required: true,\\n+ default: false,\\n+ },\\n uploadedDocs: {\\n type: [uploadedDocsSchema],\\n required: false,\\ndiff --git a/app/api/options/route.js b/app/api/options/route.js\\nindex 1bda936..84173af 100644\\n--- a/app/api/options/route.js\\n+++ b/app/api/options/route.js\\n@@ -5,7 +5,14 @@ export async function POST(req) {\\n try {\\n const body = await req.json();\\n \\n- const { userId, contextId, aiMemorySelfModify, aiName, aiStyle } = body;\\n+ const {\\n+ userId,\\n+ contextId,\\n+ aiMemorySelfModify,\\n+ aiName,\\n+ aiStyle,\\n+ streamingEnabled,\\n+ } = body;\\n \\n if (!mongoose.connection.readyState) {\\n throw new Error(\\\"Database is not connected\\\");\\n@@ -26,6 +33,9 @@ export async function POST(req) {\\n if (aiStyle !== undefined) {\\n user.aiStyle = aiStyle;\\n }\\n+ if (streamingEnabled !== undefined) {\\n+ user.streamingEnabled = streamingEnabled;\\n+ }\\n await user.save();\\n return Response.json({ status: \\\"success\\\" });\\n } else {\\ndiff --git a/app/api/request-progress/route.js b/app/api/request-progress/route.js\\nnew file mode 100644\\nindex 0000000..506f11d\\n--- /dev/null\\n+++ b/app/api/request-progress/route.js\\n@@ -0,0 +1,64 @@\\n+import RequestProgress from \\\"../models/request-progress\\\";\\n+import { NextResponse } from \\\"next/server\\\";\\n+import { getCurrentUser } from \\\"../utils/auth\\\";\\n+\\n+export async function GET(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { searchParams } = new URL(request.url);\\n+ const showDismissed = searchParams.get(\\\"showDismissed\\\") === \\\"true\\\";\\n+ const page = parseInt(searchParams.get(\\\"page\\\")) || 1;\\n+ const limit = parseInt(searchParams.get(\\\"limit\\\")) || 10;\\n+\\n+ const query = {\\n+ owner: user._id,\\n+ };\\n+\\n+ if (!showDismissed) {\\n+ query.dismissed = { $ne: true };\\n+ const fortyEightHoursAgo = new Date(\\n+ Date.now() - 48 * 60 * 60 * 1000,\\n+ );\\n+ query.createdAt = { $gte: fortyEightHoursAgo };\\n+ }\\n+\\n+ const requests = await RequestProgress.find(query)\\n+ .sort({ createdAt: -1 })\\n+ .skip((page - 1) * limit)\\n+ .limit(limit);\\n+\\n+ const total = await RequestProgress.countDocuments(query);\\n+\\n+ return NextResponse.json({\\n+ requests,\\n+ hasMore: total > page * limit,\\n+ });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\n+\\n+export async function PATCH(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { requestId } = await request.json();\\n+ await RequestProgress.findOneAndUpdate(\\n+ { requestId, owner: user._id },\\n+ { dismissed: true },\\n+ );\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\n+\\n+export async function DELETE(request) {\\n+ try {\\n+ const user = await getCurrentUser();\\n+ const { requestId } = await request.json();\\n+ await RequestProgress.findOneAndDelete({ requestId, owner: user._id });\\n+ return NextResponse.json({ success: true });\\n+ } catch (error) {\\n+ return NextResponse.json({ error: error.message }, { status: 500 });\\n+ }\\n+}\\ndiff --git a/app/api/users/me/state/route.js b/app/api/users/me/state/route.js\\nindex aa35018..7310e59 100644\\n--- a/app/api/users/me/state/route.js\\n+++ b/app/api/users/me/state/route.js\\n@@ -1,4 +1,4 @@\\n-import UserState from \\\"../../../models/user-state\\\";\\n+import UserState from \\\"../../../models/user-state.mjs\\\";\\n import { getCurrentUser } from \\\"../../../utils/auth\\\";\\n \\n function transformUserState(userState) {\\ndiff --git a/app/notifications/NotificationsPage.js b/app/notifications/NotificationsPage.js\\nnew file mode 100644\\nindex 0000000..ce16b64\\n--- /dev/null\\n+++ b/app/notifications/NotificationsPage.js\\n@@ -0,0 +1,220 @@\\n+\\\"use client\\\";\\n+import { TrashIcon, XIcon } from \\\"lucide-react\\\";\\n+import { useEffect, useState, useCallback } from \\\"react\\\";\\n+import { useTranslation } from \\\"react-i18next\\\";\\n+import { useInView } from \\\"react-intersection-observer\\\";\\n+import TimeAgo from \\\"react-time-ago\\\";\\n+import stringcase from \\\"stringcase\\\";\\n+import {\\n+ useDeleteNotification,\\n+ useInfiniteNotifications,\\n+ useCancelRequest,\\n+} from \\\"../../app/queries/notifications\\\";\\n+import {\\n+ NotificationDisplayType,\\n+ StatusIndicator,\\n+ getStatusColorClass,\\n+} from \\\"../../src/components/notifications/NotificationButton\\\";\\n+import {\\n+ AlertDialog,\\n+ AlertDialogAction,\\n+ AlertDialogCancel,\\n+ AlertDialogContent,\\n+ AlertDialogDescription,\\n+ AlertDialogFooter,\\n+ AlertDialogHeader,\\n+ AlertDialogTitle,\\n+} from \\\"@/components/ui/alert-dialog\\\";\\n+\\n+export default function NotificationsPage() {\\n+ const { t } = useTranslation();\\n+ const { ref, inView } = useInView();\\n+\\n+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } =\\n+ useInfiniteNotifications();\\n+\\n+ const deleteNotification = useDeleteNotification();\\n+ const [cancelRequestId, setCancelRequestId] = useState(null);\\n+ const cancelRequest = useCancelRequest();\\n+\\n+ useEffect(() => {\\n+ if (inView && hasNextPage) {\\n+ fetchNextPage();\\n+ }\\n+ }, [inView, hasNextPage, fetchNextPage]);\\n+\\n+ const handleDelete = (requestId) => {\\n+ if (\\n+ window.confirm(\\n+ t(\\\"Are you sure you want to delete this notification?\\\"),\\n+ )\\n+ ) {\\n+ deleteNotification.mutate(requestId);\\n+ }\\n+ };\\n+\\n+ const handleCancelRequest = (requestId) => {\\n+ setCancelRequestId(requestId);\\n+ };\\n+\\n+ const confirmCancel = useCallback(async () => {\\n+ if (cancelRequestId) {\\n+ await cancelRequest.mutate(cancelRequestId);\\n+ setCancelRequestId(null);\\n+ }\\n+ }, [cancelRequestId, cancelRequest]);\\n+\\n+ const notifications = data?.pages.flatMap((page) => page.requests) ?? [];\\n+\\n+ return (\\n+ <div className=\\\"p-2\\\">\\n+ <h1 className=\\\"text-2xl font-bold mb-6\\\">\\n+ {t(\\\"All notifications\\\")}\\n+ </h1>\\n+ <div className=\\\"space-y-4\\\">\\n+ {status === \\\"pending\\\" ? (\\n+ <div className=\\\"flex justify-center\\\">\\n+ <div className=\\\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900\\\" />\\n+ </div>\\n+ ) : notifications.length === 0 ? (\\n+ <p className=\\\"text-sm text-gray-500\\\">\\n+ {t(\\\"No notifications\\\")}\\n+ </p>\\n+ ) : (\\n+ <>\\n+ {notifications.map((notification) => (\\n+ <div\\n+ key={notification.requestId}\\n+ className=\\\"space-y-2 bg-gray-100 p-3 rounded-md\\\"\\n+ >\\n+ <div className=\\\"flex gap-3\\\">\\n+ <div className=\\\"ps-1 pt-1\\\">\\n+ <StatusIndicator\\n+ status={notification.status}\\n+ />\\n+ </div>\\n+ <div className=\\\"flex flex-col grow overflow-hidden\\\">\\n+ <div className=\\\"flex justify-between items-start\\\">\\n+ <span className=\\\"font-semibold\\\">\\n+ {t(\\n+ NotificationDisplayType[\\n+ notification.type\\n+ ],\\n+ )}\\n+ </span>\\n+ <div className=\\\"flex gap-2\\\">\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <button\\n+ onClick={() =>\\n+ handleCancelRequest(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 rounded flex items-center gap-1 text-sm text-gray-500 hover:text-red-500\\\"\\n+ title={t(\\\"Cancel\\\")}\\n+ >\\n+ <XIcon className=\\\"h-4 w-4\\\" />\\n+ </button>\\n+ )}\\n+ {(notification.status ===\\n+ \\\"completed\\\" ||\\n+ notification.status ===\\n+ \\\"failed\\\" ||\\n+ notification.status ===\\n+ \\\"cancelled\\\") && (\\n+ <button\\n+ onClick={() =>\\n+ handleDelete(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 rounded flex items-center gap-1 text-sm text-gray-500 hover:text-red-500\\\"\\n+ title={t(\\\"Delete\\\")}\\n+ >\\n+ <TrashIcon className=\\\"h-4 w-4\\\" />\\n+ </button>\\n+ )}\\n+ </div>\\n+ </div>\\n+ {notification.createdAt && (\\n+ <span className=\\\"text-xs text-gray-500\\\">\\n+ {t(\\\"Created \\\")}{\\\" \\\"}\\n+ <TimeAgo\\n+ date={\\n+ notification.createdAt\\n+ }\\n+ />\\n+ </span>\\n+ )}\\n+ <span\\n+ className={`text-sm ${notification.status === \\\"failed\\\" ? \\\"text-red-500\\\" : \\\"text-gray-500\\\"}`}\\n+ >\\n+ {notification.statusText ||\\n+ (notification.status ===\\n+ \\\"failed\\\"\\n+ ? t(\\\"Request failed\\\")\\n+ : \\\"\\\")}\\n+ </span>\\n+ <span\\n+ className={`text-sm font-semibold ${getStatusColorClass(notification.status)}`}\\n+ >\\n+ {t(\\n+ stringcase.sentencecase(\\n+ notification.status,\\n+ ),\\n+ )}\\n+ </span>\\n+\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <div className=\\\"my-2 h-2 w-full bg-gray-200 rounded-full\\\">\\n+ <div\\n+ className=\\\"h-full bg-sky-600 rounded-full transition-all duration-300\\\"\\n+ style={{\\n+ width: `${notification.progress * 100}%`,\\n+ }}\\n+ />\\n+ </div>\\n+ )}\\n+ </div>\\n+ </div>\\n+ </div>\\n+ ))}\\n+\\n+ <div ref={ref} className=\\\"py-4\\\">\\n+ {isFetchingNextPage && (\\n+ <div className=\\\"flex justify-center\\\">\\n+ <div className=\\\"animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900\\\" />\\n+ </div>\\n+ )}\\n+ </div>\\n+ </>\\n+ )}\\n+ </div>\\n+ <AlertDialog\\n+ open={!!cancelRequestId}\\n+ onOpenChange={() => setCancelRequestId(null)}\\n+ >\\n+ <AlertDialogContent>\\n+ <AlertDialogHeader>\\n+ <AlertDialogTitle>\\n+ {t(\\\"Confirm Cancellation\\\")}\\n+ </AlertDialogTitle>\\n+ <AlertDialogDescription>\\n+ {t(\\n+ \\\"Are you sure you want to cancel this request? This action cannot be undone.\\\",\\n+ )}\\n+ </AlertDialogDescription>\\n+ </AlertDialogHeader>\\n+ <AlertDialogFooter>\\n+ <AlertDialogCancel>{t(\\\"No\\\")}</AlertDialogCancel>\\n+ <AlertDialogAction onClick={confirmCancel}>\\n+ {t(\\\"Yes, Cancel Request\\\")}\\n+ </AlertDialogAction>\\n+ </AlertDialogFooter>\\n+ </AlertDialogContent>\\n+ </AlertDialog>\\n+ </div>\\n+ );\\n+}\\ndiff --git a/app/notifications/page.js b/app/notifications/page.js\\nnew file mode 100644\\nindex 0000000..10354aa\\n--- /dev/null\\n+++ b/app/notifications/page.js\\n@@ -0,0 +1,5 @@\\n+import NotificationsPage from \\\"./NotificationsPage\\\";\\n+\\n+export default function Page() {\\n+ return <NotificationsPage />;\\n+}\\ndiff --git a/app/providers.js b/app/providers.js\\nindex 86f9bc3..24a2a6b 100644\\n--- a/app/providers.js\\n+++ b/app/providers.js\\n@@ -1,6 +1,7 @@\\n // In Next.js, this file would be called: app/providers.jsx\\n \\\"use client\\\";\\n import { QueryClient, QueryClientProvider } from \\\"@tanstack/react-query\\\";\\n+import { NotificationProvider } from \\\"../src/contexts/NotificationContext\\\";\\n \\n function makeQueryClient() {\\n return new QueryClient({\\n@@ -39,7 +40,7 @@ export default function Providers({ children }) {\\n \\n return (\\n <QueryClientProvider client={queryClient}>\\n- {children}\\n+ <NotificationProvider>{children}</NotificationProvider>\\n </QueryClientProvider>\\n );\\n }\\ndiff --git a/app/queries/chats.js b/app/queries/chats.js\\nindex 186fb53..718c26a 100644\\n--- a/app/queries/chats.js\\n+++ b/app/queries/chats.js\\n@@ -38,10 +38,21 @@ export function useGetActiveChats() {\\n activeChats.forEach((chat) => {\\n const existingChat =\\n queryClient.getQueryData([\\\"chat\\\", chat._id]) || {};\\n- queryClient.setQueryData([\\\"chat\\\", chat._id], {\\n- ...existingChat,\\n- ...chat,\\n- });\\n+\\n+ // If chat has a firstMessage property but the existing chat has a full messages array,\\n+ // keep the existing messages and don't overwrite with the truncated version\\n+ const updatedChat = { ...existingChat, ...chat };\\n+\\n+ // Only preserve existing messages if they exist and are not empty\\n+ if (\\n+ chat.firstMessage &&\\n+ existingChat.messages &&\\n+ existingChat.messages.length > 0\\n+ ) {\\n+ updatedChat.messages = existingChat.messages;\\n+ }\\n+\\n+ queryClient.setQueryData([\\\"chat\\\", chat._id], updatedChat);\\n });\\n return activeChats;\\n },\\n@@ -53,10 +64,12 @@ export function useGetActiveChats() {\\n }\\n \\n function temporaryNewChat({ messages, title }) {\\n+ const tempId = `temp_${Date.now()}_${crypto.randomUUID()}`;\\n return {\\n- _id: null,\\n+ _id: tempId,\\n messages: messages || [],\\n title: title || \\\"\\\",\\n+ isTemporary: true,\\n };\\n }\\n \\n@@ -71,52 +84,170 @@ export function useAddChat() {\\n });\\n return response.data;\\n },\\n- onMutate: async ({ messages, title }) => {\\n+ // Using the standard Tanstack Query pattern for optimistic updates\\n+ onMutate: async (newChatData) => {\\n+ // Cancel related queries to prevent race conditions\\n+ await queryClient.cancelQueries({\\n+ queryKey: [\\\"activeChats\\\", \\\"userChatInfo\\\", \\\"chats\\\"],\\n+ });\\n+\\n+ // Snapshot the current state\\n const previousActiveChats =\\n queryClient.getQueryData([\\\"activeChats\\\"]) || [];\\n const previousUserChatInfo =\\n queryClient.getQueryData([\\\"userChatInfo\\\"]) || {};\\n- const newChat = temporaryNewChat({ messages, title });\\n \\n+ // Create an optimistic chat entry\\n+ const optimisticChat = temporaryNewChat(newChatData);\\n+\\n+ // Update all relevant query data optimistically\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", optimisticChat._id],\\n+ optimisticChat,\\n+ );\\n queryClient.setQueryData(\\n [\\\"activeChats\\\"],\\n- [newChat, ...previousActiveChats],\\n+ [optimisticChat, ...previousActiveChats],\\n );\\n queryClient.setQueryData([\\\"userChatInfo\\\"], {\\n ...previousUserChatInfo,\\n- activeChatId: newChat._id,\\n+ activeChatId: optimisticChat._id,\\n+ recentChatIds: previousUserChatInfo.recentChatIds\\n+ ? [\\n+ optimisticChat._id,\\n+ ...previousUserChatInfo.recentChatIds\\n+ .filter((id) => id !== optimisticChat._id)\\n+ .slice(0, 2),\\n+ ]\\n+ : [optimisticChat._id],\\n });\\n \\n- return { previousActiveChats, previousUserChatInfo };\\n- },\\n- onSuccess: (newChat) => {\\n- queryClient.setQueryData([\\\"chat\\\", newChat._id], newChat);\\n- queryClient.setQueryData([\\\"activeChats\\\"], (oldChats = []) => [\\n- newChat,\\n- ...oldChats.filter(\\n- (chat) => chat._id !== null && chat._id !== newChat._id,\\n- ),\\n- ]);\\n- queryClient.setQueryData([\\\"userChatInfo\\\"], (oldInfo) => ({\\n- ...oldInfo,\\n- activeChatId: newChat._id,\\n- }));\\n- queryClient.invalidateQueries({ queryKey: [\\\"userChatInfo\\\"] });\\n- queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\n- queryClient.invalidateQueries({ queryKey: [\\\"chats\\\"] });\\n+ // Return context for potential rollback\\n+ return {\\n+ previousActiveChats,\\n+ previousUserChatInfo,\\n+ optimisticChatId: optimisticChat._id,\\n+ };\\n },\\n- onError: (err, variables, context) => {\\n- if (context?.previousActiveChats) {\\n+ onError: (err, newChat, context) => {\\n+ // On error, roll back to the previous state\\n+ if (context) {\\n queryClient.setQueryData(\\n [\\\"activeChats\\\"],\\n context.previousActiveChats,\\n );\\n- }\\n- if (context?.previousUserChatInfo) {\\n queryClient.setQueryData(\\n [\\\"userChatInfo\\\"],\\n context.previousUserChatInfo,\\n );\\n+ queryClient.removeQueries({\\n+ queryKey: [\\\"chat\\\", context.optimisticChatId],\\n+ });\\n+ }\\n+ },\\n+ onSuccess: (serverChat, variables, context) => {\\n+ // Remove the optimistic entry\\n+ if (context?.optimisticChatId) {\\n+ queryClient.removeQueries({\\n+ queryKey: [\\\"chat\\\", context.optimisticChatId],\\n+ });\\n+ }\\n+\\n+ // Add the confirmed server data\\n+ queryClient.setQueryData([\\\"chat\\\", serverChat._id], serverChat);\\n+\\n+ // Update active chats by replacing the optimistic version\\n+ queryClient.setQueryData([\\\"activeChats\\\"], (oldData = []) => {\\n+ return [\\n+ serverChat,\\n+ ...oldData.filter(\\n+ (chat) =>\\n+ chat._id !== context?.optimisticChatId &&\\n+ chat._id !== serverChat._id,\\n+ ),\\n+ ];\\n+ });\\n+\\n+ // Update the userChatInfo with the actual chat ID\\n+ queryClient.setQueryData([\\\"userChatInfo\\\"], (oldData = {}) => {\\n+ return {\\n+ ...oldData,\\n+ activeChatId: serverChat._id,\\n+ recentChatIds: oldData.recentChatIds\\n+ ? [\\n+ serverChat._id,\\n+ ...oldData.recentChatIds.filter(\\n+ (id) =>\\n+ id !== context?.optimisticChatId &&\\n+ id !== serverChat._id,\\n+ ),\\n+ ]\\n+ : [serverChat._id],\\n+ };\\n+ });\\n+ },\\n+ onSettled: () => {\\n+ // Always refresh the data to ensure consistency\\n+ queryClient.invalidateQueries({ queryKey: [\\\"chats\\\"] });\\n+ queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\n+ queryClient.invalidateQueries({ queryKey: [\\\"userChatInfo\\\"] });\\n+ },\\n+ });\\n+}\\n+\\n+// The useAddMessage function will now automatically leverage the optimistic behavior\\n+// of useAddChat if no chatId is provided\\n+export function useAddMessage() {\\n+ const queryClient = useQueryClient();\\n+ const addChatMutation = useAddChat();\\n+\\n+ return useMutation({\\n+ mutationFn: async ({ message, chatId }) => {\\n+ let chatData;\\n+ if (!chatId) {\\n+ // No changes needed here - the optimistic updates are handled in useAddChat\\n+ const newChat = await addChatMutation.mutateAsync({\\n+ messages: [message],\\n+ });\\n+ chatId = String(newChat?._id);\\n+ chatData = newChat;\\n+ } else {\\n+ const chatResponse = await axios.post(\\n+ `/api/chats/${String(chatId)}`,\\n+ { message },\\n+ );\\n+ chatData = chatResponse.data;\\n+ queryClient.setQueryData([\\\"chat\\\", String(chatId)], chatData);\\n+ }\\n+ return chatData;\\n+ },\\n+ onMutate: ({ message, chatId }) => {\\n+ if (!chatId || !message) return;\\n+ const existingChat = queryClient.getQueryData([\\n+ \\\"chat\\\",\\n+ String(chatId),\\n+ ]);\\n+ const expectedChatData = {\\n+ ...existingChat,\\n+ messages: [...(existingChat?.messages || []), message],\\n+ };\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(chatId)],\\n+ expectedChatData,\\n+ );\\n+ },\\n+ onSuccess: (updatedChat) => {\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(updatedChat?._id)],\\n+ updatedChat,\\n+ );\\n+ },\\n+ onError: (err, variables, context) => {\\n+ if (context?.previousChat) {\\n+ queryClient.setQueryData(\\n+ [\\\"chat\\\", String(context.previousChat._id)],\\n+ context.previousChat,\\n+ );\\n }\\n },\\n });\\n@@ -206,14 +337,39 @@ export function useGetActiveChatId() {\\n }\\n \\n export function useGetChatById(chatId) {\\n+ const queryClient = useQueryClient();\\n+\\n return useQuery({\\n queryKey: [\\\"chat\\\", chatId],\\n queryFn: async () => {\\n if (!chatId) throw new Error(\\\"chatId is required\\\");\\n+\\n+ // Track this query with a timestamp to identify outdated responses\\n+ const requestTimestamp = Date.now();\\n+ queryClient.setQueryData(\\n+ [\\\"chatRequestTimestamp\\\", chatId],\\n+ requestTimestamp,\\n+ );\\n+\\n const response = await axios.get(`/api/chats/${String(chatId)}`);\\n+\\n+ // Check if this response is still the most recent one\\n+ const currentTimestamp =\\n+ queryClient.getQueryData([\\\"chatRequestTimestamp\\\", chatId]) || 0;\\n+ if (requestTimestamp < currentTimestamp) {\\n+ // Return the current data instead of the outdated response\\n+ return (\\n+ queryClient.getQueryData([\\\"chat\\\", chatId]) || response.data\\n+ );\\n+ }\\n+\\n return response.data;\\n },\\n enabled: !!chatId,\\n+ // Reduce stale time to ensure more frequent refreshes\\n+ staleTime: 1000 * 60, // 1 minute\\n+ // Add refetchOnMount to ensure fresh data when switching chats\\n+ refetchOnMount: true,\\n });\\n }\\n \\n@@ -303,68 +459,9 @@ export function useSetActiveChatId() {\\n }\\n return previousData;\\n },\\n- });\\n-}\\n-\\n-export function useAddMessage() {\\n- const queryClient = useQueryClient();\\n- const addChatMutation = useAddChat();\\n-\\n- return useMutation({\\n- mutationFn: async ({ message, chatId }) => {\\n- let chatData;\\n- if (!chatId) {\\n- const newChat = await addChatMutation.mutateAsync({\\n- messages: [message],\\n- });\\n- chatId = String(newChat?._id);\\n- chatData = newChat;\\n- queryClient.setQueryData([\\\"chats\\\"], (old = []) => [\\n- newChat,\\n- ...old,\\n- ]);\\n- queryClient.setQueryData([\\\"activeChats\\\"], (old = []) => [\\n- newChat,\\n- ...old,\\n- ]);\\n- } else {\\n- const chatResponse = await axios.post(\\n- `/api/chats/${String(chatId)}`,\\n- { message },\\n- );\\n- chatData = chatResponse.data;\\n- queryClient.setQueryData([\\\"chat\\\", String(chatId)], chatData);\\n- }\\n- return chatData;\\n- },\\n- onMutate: ({ message, chatId }) => {\\n- if (!chatId || !message) return;\\n- const existingChat = queryClient.getQueryData([\\n- \\\"chat\\\",\\n- String(chatId),\\n- ]);\\n- const expectedChatData = {\\n- ...existingChat,\\n- messages: [...(existingChat?.messages || []), message],\\n- };\\n- queryClient.setQueryData(\\n- [\\\"chat\\\", String(chatId)],\\n- expectedChatData,\\n- );\\n- },\\n- onSuccess: (updatedChat) => {\\n- queryClient.setQueryData(\\n- [\\\"chat\\\", String(updatedChat?._id)],\\n- updatedChat,\\n- );\\n- },\\n- onError: (err, variables, context) => {\\n- if (context?.previousChat) {\\n- queryClient.setQueryData(\\n- [\\\"chat\\\", String(context.previousChat._id)],\\n- context.previousChat,\\n- );\\n- }\\n+ onSuccess: () => {\\n+ // Simply mark the queries as stale after setting the active chat ID\\n+ queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\n },\\n });\\n }\\n@@ -377,17 +474,33 @@ export function useUpdateChat() {\\n if (!chatId) {\\n throw new Error(\\\"chatId is required\\\");\\n }\\n+\\n+ // Track this mutation with a timestamp\\n+ const requestTimestamp = Date.now();\\n+ queryClient.setQueryData(\\n+ [\\\"chatRequestTimestamp\\\", chatId],\\n+ requestTimestamp,\\n+ );\\n+\\n const response = await axios.put(\\n `/api/chats/${String(chatId)}`,\\n updateData,\\n );\\n- return response.data;\\n+\\n+ return { data: response.data, timestamp: requestTimestamp };\\n },\\n onMutate: async ({ chatId, ...updateData }) => {\\n await queryClient.cancelQueries({ queryKey: [\\\"chat\\\", chatId] });\\n await queryClient.cancelQueries({ queryKey: [\\\"chats\\\"] });\\n await queryClient.cancelQueries({ queryKey: [\\\"activeChats\\\"] });\\n \\n+ // Track this mutation with a timestamp\\n+ const requestTimestamp = Date.now();\\n+ queryClient.setQueryData(\\n+ [\\\"chatRequestTimestamp\\\", chatId],\\n+ requestTimestamp,\\n+ );\\n+\\n const previousChat = queryClient.getQueryData([\\\"chat\\\", chatId]);\\n const expectedChatData = { ...previousChat, ...updateData };\\n \\n@@ -413,7 +526,7 @@ export function useUpdateChat() {\\n ) || [],\\n );\\n \\n- return { previousChat };\\n+ return { previousChat, timestamp: requestTimestamp };\\n },\\n onError: (err, variables, context) => {\\n if (context?.previousChat) {\\n@@ -423,7 +536,20 @@ export function useUpdateChat() {\\n );\\n }\\n },\\n- onSuccess: (updatedChat, { chatId }) => {\\n+ onSuccess: (result, { chatId }) => {\\n+ const { data: updatedChat, timestamp } = result;\\n+\\n+ // Check if this response is still the most recent one\\n+ const currentTimestamp =\\n+ queryClient.getQueryData([\\\"chatRequestTimestamp\\\", chatId]) || 0;\\n+ if (timestamp < currentTimestamp) {\\n+ console.log(\\n+ \\\"[useUpdateChat:onSuccess] Ignoring outdated response for\\\",\\n+ chatId,\\n+ );\\n+ return;\\n+ }\\n+\\n queryClient.setQueryData([\\\"chat\\\", chatId], updatedChat);\\n queryClient.invalidateQueries({ queryKey: [\\\"chats\\\"] });\\n queryClient.invalidateQueries({ queryKey: [\\\"activeChats\\\"] });\\ndiff --git a/app/queries/notifications.js b/app/queries/notifications.js\\nnew file mode 100644\\nindex 0000000..4df777f\\n--- /dev/null\\n+++ b/app/queries/notifications.js\\n@@ -0,0 +1,129 @@\\n+import {\\n+ useQuery,\\n+ useMutation,\\n+ useQueryClient,\\n+ useInfiniteQuery,\\n+} from \\\"@tanstack/react-query\\\";\\n+import axios from \\\"../utils/axios-client\\\";\\n+import { useContext } from \\\"react\\\";\\n+import { AuthContext } from \\\"../../src/App\\\";\\n+\\n+export function useNotifications(showDismissed = false) {\\n+ const queryClient = useQueryClient();\\n+ const previousData = queryClient.getQueryData([\\n+ \\\"notifications\\\",\\n+ showDismissed,\\n+ ]);\\n+ const { refetchUserState } = useContext(AuthContext);\\n+\\n+ const invalidateNotifications = () => {\\n+ queryClient.invalidateQueries({ queryKey: [\\\"notifications\\\"] });\\n+ };\\n+\\n+ const query = useQuery({\\n+ queryKey: [\\\"notifications\\\", showDismissed],\\n+ queryFn: async () => {\\n+ const { data } = await axios.get(\\n+ `/api/request-progress?showDismissed=${showDismissed}`,\\n+ );\\n+\\n+ // Check if any notification has newly completed\\n+ if (previousData?.requests) {\\n+ const newlyCompleted = data.requests.some(\\n+ (notification) =>\\n+ notification.status === \\\"completed\\\" &&\\n+ previousData.requests.find(\\n+ (prev) =>\\n+ prev.requestId === notification.requestId &&\\n+ prev.status !== \\\"completed\\\",\\n+ ),\\n+ );\\n+\\n+ if (newlyCompleted) {\\n+ // Refetch user state when a notification completes\\n+ refetchUserState();\\n+ }\\n+ }\\n+\\n+ return data;\\n+ },\\n+ refetchInterval: (query) => {\\n+ const requests = query.state.data?.requests;\\n+ if (\\n+ requests?.some(\\n+ (notification) => notification.status === \\\"in_progress\\\",\\n+ )\\n+ ) {\\n+ return 5000;\\n+ } else {\\n+ return false;\\n+ }\\n+ },\\n+ refetchIntervalInBackground: true,\\n+ });\\n+\\n+ return { ...query, invalidateNotifications };\\n+}\\n+\\n+export function useDeleteNotification() {\\n+ const queryClient = useQueryClient();\\n+\\n+ return useMutation({\\n+ mutationFn: async (requestId) => {\\n+ const response = await axios.delete(\\\"/api/request-progress\\\", {\\n+ data: { requestId },\\n+ });\\n+ return response.data;\\n+ },\\n+ onSuccess: () => {\\n+ queryClient.invalidateQueries({ queryKey: [\\\"notifications\\\"] });\\n+ },\\n+ });\\n+}\\n+\\n+export function useDismissNotification() {\\n+ const queryClient = useQueryClient();\\n+\\n+ return useMutation({\\n+ mutationFn: async (requestId) => {\\n+ const response = await axios.patch(\\\"/api/request-progress\\\", {\\n+ requestId,\\n+ });\\n+ return response.data;\\n+ },\\n+ onSuccess: () => {\\n+ queryClient.invalidateQueries({ queryKey: [\\\"notifications\\\"] });\\n+ },\\n+ });\\n+}\\n+\\n+export function useCancelRequest() {\\n+ const queryClient = useQueryClient();\\n+\\n+ return useMutation({\\n+ mutationFn: async (requestId) => {\\n+ const response = await axios.post(\\\"/api/cancel-request\\\", {\\n+ requestId,\\n+ });\\n+ return response.data;\\n+ },\\n+ onSuccess: () => {\\n+ queryClient.invalidateQueries({ queryKey: [\\\"notifications\\\"] });\\n+ },\\n+ });\\n+}\\n+\\n+export function useInfiniteNotifications() {\\n+ return useInfiniteQuery({\\n+ queryKey: [\\\"notifications\\\", \\\"infinite\\\", true],\\n+ queryFn: async ({ pageParam = 1 }) => {\\n+ const response = await fetch(\\n+ `/api/request-progress?showDismissed=true&page=${pageParam}&limit=10`,\\n+ );\\n+ return response.json();\\n+ },\\n+ getNextPageParam: (lastPage, pages) => {\\n+ return lastPage.hasMore ? pages.length + 1 : undefined;\\n+ },\\n+ });\\n+}\\ndiff --git a/app/queries/options.js b/app/queries/options.js\\nindex e2813e3..d25ff34 100644\\n--- a/app/queries/options.js\\n+++ b/app/queries/options.js\\n@@ -1,13 +1,7 @@\\n import { useMutation, useQueryClient } from \\\"@tanstack/react-query\\\";\\n import axios from \\\"../utils/axios-client\\\";\\n \\n-export function useUpdateAiOptions(\\n- userId,\\n- contextId,\\n- aiMemorySelfModify,\\n- aiName,\\n- aiStyle,\\n-) {\\n+export function useUpdateAiOptions() {\\n const queryClient = useQueryClient();\\n \\n const mutation = useMutation({\\n@@ -17,6 +11,7 @@ export function useUpdateAiOptions(\\n aiMemorySelfModify,\\n aiName,\\n aiStyle,\\n+ streamingEnabled,\\n }) => {\\n // persist it to user options in the database\\n const response = await axios.post(`/api/options`, {\\n@@ -25,6 +20,7 @@ export function useUpdateAiOptions(\\n aiMemorySelfModify,\\n aiName,\\n aiStyle,\\n+ streamingEnabled,\\n });\\n return response.data;\\n },\\n@@ -34,6 +30,7 @@ export function useUpdateAiOptions(\\n aiMemorySelfModify,\\n aiName,\\n aiStyle,\\n+ streamingEnabled,\\n }) => {\\n await queryClient.cancelQueries({ queryKey: [\\\"currentUser\\\"] });\\n const previousUser = await queryClient.getQueryData([\\n@@ -47,6 +44,7 @@ export function useUpdateAiOptions(\\n aiMemorySelfModify,\\n aiName,\\n aiStyle,\\n+ streamingEnabled,\\n };\\n });\\n \\ndiff --git a/app/utils/video-state-handler.js b/app/utils/video-state-handler.js\\nnew file mode 100644\\nindex 0000000..5e7070b\\n--- /dev/null\\n+++ b/app/utils/video-state-handler.js\\n@@ -0,0 +1,131 @@\\n+async function fetchVttContent(url) {\\n+ try {\\n+ const response = await fetch(url);\\n+ if (!response.ok) {\\n+ throw new Error(\\n+ `Failed to fetch VTT content: ${response.statusText}`,\\n+ );\\n+ }\\n+ return await response.text();\\n+ } catch (error) {\\n+ console.error(`Error fetching VTT content from ${url}:`, error);\\n+ throw error;\\n+ }\\n+}\\n+\\n+async function handleVideoTranslationCompletion(\\n+ userId,\\n+ dataObject,\\n+ targetLocaleLabel,\\n+) {\\n+ if (!userId || !dataObject) {\\n+ console.log(\\\"Missing required data for video state update\\\");\\n+ return;\\n+ }\\n+\\n+ try {\\n+ const UserState = (await import(\\\"../api/models/user-state.mjs\\\"))\\n+ .default;\\n+ const userState = await UserState.findOne({ user: userId });\\n+ if (!userState) {\\n+ console.log(\\\"User state not found\\\");\\n+ return;\\n+ }\\n+\\n+ let state = {};\\n+ try {\\n+ state = userState.serializedState\\n+ ? JSON.parse(userState.serializedState)\\n+ : {};\\n+ } catch (e) {\\n+ console.error(\\\"Error parsing serializedState:\\\", e);\\n+ state = {};\\n+ }\\n+\\n+ // Get the target locale and URLs from the data\\n+ const targetLocale = Object.keys(dataObject.targetLocales)[0];\\n+ const targetVideoUrl =\\n+ dataObject.targetLocales[targetLocale].outputVideoFileUrl;\\n+ const originalVttUrl = dataObject.outputVideoSubtitleWebVttFileUrl;\\n+ const translatedVttUrl =\\n+ dataObject.targetLocales[targetLocale]\\n+ .outputVideoSubtitleWebVttFileUrl;\\n+\\n+ // Update the transcribe state\\n+ const transcribeState = state.transcribe || {};\\n+ const videoInformation = transcribeState.videoInformation || {};\\n+\\n+ // Update video languages with new format including label\\n+ const videoLanguages = videoInformation.videoLanguages || [];\\n+ videoLanguages.push({\\n+ code: targetLocale,\\n+ url: targetVideoUrl,\\n+ });\\n+\\n+ // Update transcripts\\n+ const transcripts = transcribeState.transcripts || [];\\n+\\n+ // Try to add original subtitles if they don't exist\\n+ const autoSubtitlesExist = transcripts.some(\\n+ (transcript) => transcript.name === \\\"Original Subtitles\\\",\\n+ );\\n+\\n+ if (!autoSubtitlesExist && originalVttUrl) {\\n+ try {\\n+ const vttContent = await fetchVttContent(originalVttUrl);\\n+ transcripts.push({\\n+ url: originalVttUrl,\\n+ text: vttContent,\\n+ format: \\\"vtt\\\",\\n+ name: \\\"Original Subtitles\\\",\\n+ timestamp: new Date().toISOString(),\\n+ });\\n+ } catch (error) {\\n+ console.error(\\\"Failed to fetch original VTT content:\\\", error);\\n+ // Continue with translation even if original subtitles fail\\n+ }\\n+ }\\n+\\n+ // Try to add translated subtitles\\n+ if (translatedVttUrl) {\\n+ try {\\n+ const vttContent = await fetchVttContent(translatedVttUrl);\\n+ transcripts.push({\\n+ url: translatedVttUrl,\\n+ text: vttContent,\\n+ format: \\\"vtt\\\",\\n+ name: `${targetLocaleLabel || targetLocale} Subtitles`, // Frontend will handle proper language display\\n+ timestamp: new Date().toISOString(),\\n+ });\\n+ } catch (error) {\\n+ console.error(\\\"Failed to fetch translated VTT content:\\\", error);\\n+ }\\n+ }\\n+\\n+ // Update the state\\n+ state.transcribe = {\\n+ ...transcribeState,\\n+ videoInformation: {\\n+ ...videoInformation,\\n+ videoLanguages,\\n+ },\\n+ transcripts,\\n+ };\\n+\\n+ // Save the updated state\\n+ await UserState.findOneAndUpdate(\\n+ { user: userId },\\n+ { serializedState: JSON.stringify(state) },\\n+ );\\n+ console.log(\\n+ \\\"User state updated successfully with new video languages and transcripts\\\",\\n+ );\\n+ } catch (error) {\\n+ console.error(\\\"Error updating user state:\\\", error);\\n+ throw error;\\n+ }\\n+}\\n+\\n+module.exports = {\\n+ handleVideoTranslationCompletion,\\n+};\\ndiff --git a/app/workspaces/components/WorkspaceOutputs.js b/app/workspaces/components/WorkspaceOutputs.js\\nindex 64c2492..d1b4759 100644\\n--- a/app/workspaces/components/WorkspaceOutputs.js\\n+++ b/app/workspaces/components/WorkspaceOutputs.js\\n@@ -2,10 +2,11 @@ import { useTranslation } from \\\"react-i18next\\\";\\n import ReactTimeAgo from \\\"react-time-ago\\\";\\n import CopyButton from \\\"../../../src/components/CopyButton\\\";\\n import { convertMessageToMarkdown } from \\\"../../../src/components/chat/ChatMessage\\\";\\n+import OutputSandbox from \\\"../../../src/components/sandbox/OutputSandbox\\\";\\n \\n export default function WorkspaceOutputs({ outputs = [], onDelete }) {\\n return (\\n- <div className=\\\"flex flex-col gap-2\\\">\\n+ <div className=\\\"flex flex-col gap-4\\\">\\n {outputs.map((output) => (\\n <Output output={output} key={output._id} onDelete={onDelete} />\\n ))}\\n@@ -16,34 +17,54 @@ export default function WorkspaceOutputs({ outputs = [], onDelete }) {\\n function Output({ output, onDelete }) {\\n const { t } = useTranslation();\\n \\n+ // Check if the output is HTML content\\n+ const isHtmlContent =\\n+ output.output.trim().startsWith(\\\"<!DOCTYPE html>\\\") ||\\n+ output.output.trim().startsWith(\\\"<html>\\\") ||\\n+ (output.tool && JSON.parse(output.tool)?.isHtml);\\n+\\n return (\\n <div key={output._id} className=\\\"relative mb-3\\\">\\n- <div className=\\\"font-medium\\\">{output.title}</div>\\n- <div className=\\\"mt-3 mb-1 p-3 bg-gray-50 border rounded-md relative text-sm\\\">\\n- <CopyButton item={output.output} />\\n- {convertMessageToMarkdown({ payload: output.output })}\\n- </div>\\n- <div className=\\\"text-xs text-gray-300 flex justify-between gap-4 px-2\\\">\\n- <div>\\n- {t(\\\"Generated\\\")}{\\\" \\\"}\\n- <ReactTimeAgo date={new Date(output.createdAt)} />\\n+ <div className=\\\"font-semibold text-lg\\\">{output.title}</div>\\n+ <div className=\\\"mt-3 mb-1 p-4 bg-gray-50 border rounded-md relative\\\">\\n+ <div className=\\\"absolute top-3 right-3\\\">\\n+ <CopyButton\\n+ item={output.output}\\n+ className=\\\"opacity-60 hover:opacity-100\\\"\\n+ />\\n+ </div>\\n+ {isHtmlContent ? (\\n+ <OutputSandbox content={output.output} />\\n+ ) : (\\n+ <div className=\\\"chat-message-bot\\\">\\n+ {convertMessageToMarkdown({\\n+ payload: output.output,\\n+ tool: output.tool,\\n+ })}\\n+ </div>\\n+ )}\\n+ <div className=\\\"text-xs text-gray-400 flex justify-between gap-4 mt-4\\\">\\n+ <div>\\n+ {t(\\\"Generated\\\")}{\\\" \\\"}\\n+ <ReactTimeAgo date={new Date(output.createdAt)} />\\n+ </div>\\n+ <button\\n+ onClick={() => {\\n+ if (\\n+ window.confirm(\\n+ t(\\n+ \\\"Are you sure you want to delete this output?\\\",\\n+ ),\\n+ )\\n+ ) {\\n+ onDelete(output._id);\\n+ }\\n+ }}\\n+ className=\\\"text-gray-400 hover:text-gray-600\\\"\\n+ >\\n+ {t(\\\"Delete\\\")}\\n+ </button>\\n </div>\\n- <button\\n- onClick={() => {\\n- if (\\n- window.confirm(\\n- t(\\n- \\\"Are you sure you want to delete this output?\\\",\\n- ),\\n- )\\n- ) {\\n- onDelete(output._id);\\n- }\\n- }}\\n- className=\\\"text-gray-300 hover:text-gray-500\\\"\\n- >\\n- {t(\\\"Delete\\\")}\\n- </button>\\n </div>\\n </div>\\n );\\ndiff --git a/config/default/config/data/taxonomySets.js b/config/default/config/data/taxonomySets.js\\nindex 3ffb97c..ffaf82b 100644\\n--- a/config/default/config/data/taxonomySets.js\\n+++ b/config/default/config/data/taxonomySets.js\\n@@ -15,7 +15,7 @@ const taxonomySets = taxonomySetsContext\\n // will include the same file with two paths:\\n // ./filename.json and <absolute-path>/filename.json\\n if (dedupedFileNames.includes(filenameOnly)) {\\n- return;\\n+ return null;\\n }\\n \\n const setName = filename.slice(2, -5); // Remove './' and '.json' from the file name\\ndiff --git a/config/default/config/index.js b/config/default/config/index.js\\nindex e4c0d5c..2f41b13 100644\\n--- a/config/default/config/index.js\\n+++ b/config/default/config/index.js\\n@@ -19,7 +19,7 @@ const LLM_IDENTIFIERS = {\\n claude35sonnet: \\\"claude35sonnet\\\",\\n claude3opus: \\\"claude3opus\\\",\\n o1: \\\"o1\\\",\\n- o1mini: \\\"o1mini\\\",\\n+ o3mini: \\\"o3mini\\\",\\n };\\n \\n // eslint-disable-next-line import/no-anonymous-default-export\\n@@ -93,10 +93,10 @@ export default {\\n cortexModelName: \\\"oai-o1\\\",\\n },\\n {\\n- identifier: LLM_IDENTIFIERS.o1mini,\\n- name: \\\"o1 Mini\\\",\\n- cortexPathwayName: \\\"run_o1_mini\\\",\\n- cortexModelName: \\\"oai-o1-mini\\\",\\n+ identifier: LLM_IDENTIFIERS.o3mini,\\n+ name: \\\"o3 Mini\\\",\\n+ cortexPathwayName: \\\"run_o3_mini\\\",\\n+ cortexModelName: \\\"oai-o3-mini\\\",\\n },\\n ],\\n },\\ndiff --git a/config/default/locales/ar.json b/config/default/locales/ar.json\\nindex 99aad35..e3a8cb0 100644\\n--- a/config/default/locales/ar.json\\n+++ b/config/default/locales/ar.json\\n@@ -463,5 +463,31 @@\\n \\\"Download .txt\\\": \\\"تنزيل بتنسيق TXT\\\",\\n \\\"Taxonomy\\\": \\\"التصنيف\\\",\\n \\\"Transcript\\\": \\\"النص المنسوخ\\\",\\n- \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\"\\n+ \\\"{{name}}: {{language}} Translation\\\": \\\"{{name}}: ترجمة {{language}}\\\",\\n+ \\\"Processing media...\\\": \\\"جاري معالجة الوسائط...\\\",\\n+ \\\"Transcription type\\\": \\\"نوع التنسيق\\\",\\n+ \\\"{{from}} to {{to}}\\\": \\\"{{from}} إلى {{to}}\\\",\\n+ \\\"Video translation\\\": \\\"ترجمة الفيديو\\\",\\n+ \\\"In progress\\\": \\\"قيد التنفيذ\\\",\\n+ \\\"Completed\\\": \\\"منجز\\\",\\n+ \\\"Failed\\\": \\\"فشل\\\",\\n+ \\\"View all\\\": \\\"عرض الكل\\\",\\n+ \\\"No recent or active notifications\\\": \\\"لا يوجد إشعارات مفعلة\\\",\\n+ \\\"View history\\\": \\\"عرض التاريخ\\\",\\n+ \\\"All notifications\\\": \\\"جميع الإشعارات\\\",\\n+ \\\"Enable streaming responses\\\": \\\"تفعيل الاستجابات المنسية\\\",\\n+ \\\"Memory backup\\\": \\\"نسخة احتياطية للذاكرة\\\",\\n+ \\\"Download memory backup\\\": \\\"تنزيل نسخة احتياطية للذاكرة\\\",\\n+ \\\"Upload memory from backup\\\": \\\"تحميل الذاكرة من النسخة الاحتياطية\\\",\\n+ \\\"Failed to read the file. Please try again.\\\": \\\"فشل في قراءة الملف. يرجى المحاولة مرة أخرى.\\\",\\n+ \\\"Failed to parse memory file. Please ensure it is a valid JSON file with the correct memory structure.\\\": \\\"فشل في تحليل ملف الذاكرة. يرجى التأكد من أنه ملف JSON صالح بهيكل الذاكرة الصحيح.\\\",\\n+ \\\"Invalid memory file format\\\": \\\"تنسيق ملف الذاكرة غير صالح\\\",\\n+ \\\"Add audio track\\\": \\\"إضافة صوت\\\",\\n+ \\\"Transcript not looking right?\\\": \\\"النص المنسوخ لا يبدو صحيحًا؟\\\",\\n+ \\\"Transcribe again using an alternate model\\\": \\\"تنسيق مرة أخرى باستخدام نموذج مختلف\\\",\\n+ \\\"Re-transcribing\\\": \\\"إعادة التنسيق\\\",\\n+ \\\"Transcribing... This may take a few minutes.\\\": \\\"جاري التنسيق... قد يستغرق هذا بضع دقائق.\\\",\\n+ \\\"Auto-transcribing\\\": \\\"تنسيق تلقائي\\\",\\n+ \\\"Edit title\\\": \\\"تعديل العنوان\\\",\\n+ \\\"Delete chat\\\": \\\"حذف الدردشة\\\"\\n }\\ndiff --git a/jobs/digest/digest.utils.js b/jobs/digest/digest.utils.js\\nindex 2f304af..d73592f 100644\\n--- a/jobs/digest/digest.utils.js\\n+++ b/jobs/digest/digest.utils.js\\n@@ -1,6 +1,5 @@\\n const APPROXIMATE_DURATION_SECONDS = 60;\\n const PROGRESS_UPDATE_INTERVAL = 3000;\\n-const { processImageUrls } = require(\\\"../../src/utils/imageUtils\\\");\\n \\n const generateDigestBlockContent = async (\\n block,\\n@@ -8,6 +7,9 @@ const generateDigestBlockContent = async (\\n logger,\\n onProgressUpdate,\\n ) => {\\n+ let imageUtils = await import(\\\"../../src/utils/imageUtils.mjs\\\");\\n+ const { processImageUrls } = imageUtils;\\n+\\n let graphql = await import(\\\"../graphql.mjs\\\");\\n const { QUERIES, getClient } = graphql;\\n const { prompt } = block;\\n@@ -36,13 +38,13 @@ const generateDigestBlockContent = async (\\n \\n try {\\n const result = await client.query({\\n- query: QUERIES.RAG_START,\\n+ query: QUERIES.SYS_ENTITY_START,\\n variables,\\n });\\n \\n- tool = result.data.rag_start.tool;\\n+ tool = result.data.sys_entity_start.tool;\\n if (tool) {\\n- const toolObj = JSON.parse(result.data.rag_start.tool);\\n+ const toolObj = JSON.parse(result.data.sys_entity_start.tool);\\n toolCallbackName = toolObj?.toolCallbackName;\\n }\\n \\n@@ -67,14 +69,14 @@ const generateDigestBlockContent = async (\\n try {\\n content = JSON.stringify({\\n payload: await processImageUrls(\\n- JSON.parse(result.data.rag_start.result).response,\\n+ JSON.parse(result.data.sys_entity_start.result),\\n process.env.SERVER_URL,\\n ),\\n tool,\\n });\\n } catch (e) {\\n logger.error(\\n- `Error while parsing rag_start result: ${e.message}`,\\n+ `Error while parsing sys_entity_start result: ${e.message}`,\\n user?._id,\\n block?._id,\\n );\\ndiff --git a/jobs/graphql.mjs b/jobs/graphql.mjs\\nindex b95daac..6ea5952 100644\\n--- a/jobs/graphql.mjs\\n+++ b/jobs/graphql.mjs\\n@@ -115,7 +115,7 @@ const SYS_SAVE_MEMORY = gql`\\n }\\n `;\\n \\n-const RAG_START = gql`\\n+const SYS_ENTITY_START = gql`\\n query RagStart(\\n $chatHistory: [MultiMessage]!\\n $dataSources: [String]\\n@@ -129,7 +129,7 @@ const RAG_START = gql`\\n $title: String\\n $aiStyle: String\\n ) {\\n- rag_start(\\n+ sys_entity_start(\\n chatHistory: $chatHistory\\n dataSources: $dataSources\\n contextId: $contextId\\n@@ -493,6 +493,8 @@ const REQUEST_PROGRESS = gql`\\n requestProgress(requestIds: $requestIds) {\\n data\\n progress\\n+ info\\n+ error\\n }\\n }\\n `;\\n@@ -631,7 +633,7 @@ const QUERIES = {\\n COGNITIVE_INSERT,\\n IMAGE,\\n SYS_SAVE_MEMORY,\\n- RAG_START,\\n+ SYS_ENTITY_START,\\n SYS_ENTITY_CONTINUE,\\n EXPAND_STORY,\\n FORMAT_PARAGRAPH_TURBO,\\n@@ -687,7 +689,7 @@ export {\\n COGNITIVE_DELETE,\\n EXPAND_STORY,\\n SYS_SAVE_MEMORY,\\n- RAG_START,\\n+ SYS_ENTITY_START,\\n SYS_ENTITY_CONTINUE,\\n SELECT_SERVICES,\\n SUMMARY,\\ndiff --git a/jobs/request-progress-worker.js b/jobs/request-progress-worker.js\\nnew file mode 100644\\nindex 0000000..095cd0c\\n--- /dev/null\\n+++ b/jobs/request-progress-worker.js\\n@@ -0,0 +1,588 @@\\n+const { Worker } = require(\\\"bullmq\\\");\\n+const Redis = require(\\\"ioredis\\\");\\n+const {\\n+ ApolloClient,\\n+ InMemoryCache,\\n+ split,\\n+ HttpLink,\\n+ gql,\\n+} = require(\\\"@apollo/client\\\");\\n+const { GraphQLWsLink } = require(\\\"@apollo/client/link/subscriptions\\\");\\n+const { getMainDefinition } = require(\\\"@apollo/client/utilities\\\");\\n+const { createClient } = require(\\\"graphql-ws\\\");\\n+const WebSocket = require(\\\"ws\\\");\\n+\\n+const REQUEST_PROGRESS_SUBSCRIPTION = gql`\\n+ subscription RequestProgress($requestIds: [String!]!) {\\n+ requestProgress(requestIds: $requestIds) {\\n+ progress\\n+ data\\n+ info\\n+ error\\n+ }\\n+ }\\n+`;\\n+\\n+const graphqlEndpoint =\\n+ process.env.CORTEX_GRAPHQL_API_URL || \\\"http://localhost:4000/graphql\\\";\\n+\\n+const connection = new Redis(\\n+ process.env.REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n+ {\\n+ maxRetriesPerRequest: null,\\n+ },\\n+);\\n+\\n+const httpLink = new HttpLink({\\n+ uri: graphqlEndpoint,\\n+});\\n+\\n+const wsLink = new GraphQLWsLink(\\n+ createClient({\\n+ url: graphqlEndpoint.replace(\\\"http\\\", \\\"ws\\\"),\\n+ webSocketImpl: WebSocket,\\n+ }),\\n+);\\n+\\n+const splitLink = split(\\n+ ({ query }) => {\\n+ const definition = getMainDefinition(query);\\n+ return (\\n+ definition.kind === \\\"OperationDefinition\\\" &&\\n+ definition.operation === \\\"subscription\\\"\\n+ );\\n+ },\\n+ wsLink,\\n+ httpLink,\\n+);\\n+\\n+const client = new ApolloClient({\\n+ link: splitLink,\\n+ cache: new InMemoryCache(),\\n+});\\n+\\n+// Add a helper function for DB operations with retries\\n+async function retryDbOperation(operation, maxRetries = 3, retryDelay = 1000) {\\n+ let lastError;\\n+ for (let attempt = 1; attempt <= maxRetries; attempt++) {\\n+ try {\\n+ return await operation();\\n+ } catch (error) {\\n+ lastError = error;\\n+ console.warn(\\n+ `DB operation attempt ${attempt}/${maxRetries} failed: ${error.message}`,\\n+ );\\n+\\n+ // Check explicitly for MongoNotConnectedError and other connection issues\\n+ if (\\n+ error.name === \\\"MongoNotConnectedError\\\" ||\\n+ ((error.name === \\\"MongooseError\\\" ||\\n+ error.name === \\\"MongoError\\\") &&\\n+ error.message &&\\n+ (error.message.includes(\\\"buffering\\\") ||\\n+ error.message.includes(\\\"disconnected\\\") ||\\n+ error.message.includes(\\\"timeout\\\") ||\\n+ error.message.includes(\\\"not connected\\\") ||\\n+ error.message.includes(\\\"must be connected\\\")))\\n+ ) {\\n+ console.log(\\n+ \\\"Detected MongoDB connection issue, attempting to reconnect...\\\",\\n+ );\\n+ // Use the global mongoose instance to check connection state\\n+ const mongoose = (await import(\\\"mongoose\\\")).default;\\n+ if (mongoose.connection.readyState !== 1) {\\n+ try {\\n+ // First try to close any existing connection\\n+ if (mongoose.connection.readyState !== 0) {\\n+ await mongoose.connection\\n+ .close()\\n+ .catch((err) =>\\n+ console.warn(\\n+ \\\"Error closing existing connection:\\\",\\n+ err.message,\\n+ ),\\n+ );\\n+ }\\n+\\n+ // Get a fresh database connection\\n+ const { connectToDatabase } = await import(\\n+ \\\"../src/db.mjs\\\"\\n+ );\\n+ await connectToDatabase();\\n+ console.log(\\\"Successfully reconnected to MongoDB\\\");\\n+\\n+ // Reset the dbInitialized flag to ensure ensureDbConnection will work properly\\n+ dbInitialized = mongoose.connection.readyState === 1;\\n+ } catch (reconnectError) {\\n+ console.error(\\n+ \\\"Failed to reconnect to MongoDB:\\\",\\n+ reconnectError.message,\\n+ );\\n+ }\\n+ }\\n+ }\\n+\\n+ if (attempt < maxRetries) {\\n+ const waitTime = Math.min(retryDelay, 30000); // Cap at 30 seconds max\\n+ console.log(\\n+ `Waiting ${waitTime / 1000}s before retry ${attempt + 1}/${maxRetries}...`,\\n+ );\\n+ await new Promise((resolve) => setTimeout(resolve, waitTime));\\n+ // Increase delay for next retry (exponential backoff)\\n+ retryDelay *= 2;\\n+ }\\n+ }\\n+ }\\n+ throw lastError;\\n+}\\n+\\n+// Ensure the worker has a database connection before starting operations\\n+let dbInitialized = false;\\n+let connectionAttempts = 0;\\n+const MAX_CONNECTION_ATTEMPTS = 5;\\n+\\n+async function ensureDbConnection(forceReconnect = false) {\\n+ if (forceReconnect) {\\n+ dbInitialized = false;\\n+ }\\n+\\n+ if (!dbInitialized) {\\n+ try {\\n+ // Use the global mongoose instance directly\\n+ const mongoose = (await import(\\\"mongoose\\\")).default;\\n+\\n+ // Check if already connected\\n+ if (mongoose.connection && mongoose.connection.readyState === 1) {\\n+ console.log(\\\"Already connected to MongoDB\\\");\\n+ dbInitialized = true;\\n+ connectionAttempts = 0;\\n+ return;\\n+ }\\n+\\n+ // If previous connection exists but is disconnected, close it\\n+ if (mongoose.connection && mongoose.connection.readyState !== 0) {\\n+ console.log(\\n+ \\\"Closing existing MongoDB connection before reconnecting...\\\",\\n+ );\\n+ await mongoose.connection\\n+ .close()\\n+ .catch((err) =>\\n+ console.warn(\\\"Error closing connection:\\\", err.message),\\n+ );\\n+ }\\n+\\n+ connectionAttempts++;\\n+ console.log(\\n+ `Connecting to MongoDB (attempt ${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS})...`,\\n+ );\\n+\\n+ const { connectToDatabase } = await import(\\\"../src/db.mjs\\\");\\n+ await connectToDatabase();\\n+\\n+ // Wait a moment to ensure the connection is established\\n+ await new Promise((resolve) => setTimeout(resolve, 500));\\n+\\n+ // Verify the connection was successful\\n+ if (mongoose.connection && mongoose.connection.readyState === 1) {\\n+ console.log(\\n+ \\\"Worker successfully connected to MongoDB database\\\",\\n+ );\\n+ dbInitialized = true;\\n+ connectionAttempts = 0;\\n+ } else {\\n+ throw new Error(\\n+ `Failed to establish MongoDB connection, current state: ${mongoose.connection ? mongoose.connection.readyState : \\\"unknown\\\"}`,\\n+ );\\n+ }\\n+ } catch (error) {\\n+ console.error(\\n+ `Failed to connect to database (attempt ${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS}):`,\\n+ error,\\n+ );\\n+\\n+ if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) {\\n+ console.error(\\n+ \\\"Maximum connection attempts reached. Giving up.\\\",\\n+ );\\n+ throw new Error(\\n+ `Failed to connect to MongoDB after ${MAX_CONNECTION_ATTEMPTS} attempts: ${error.message}`,\\n+ );\\n+ }\\n+\\n+ // Wait before next attempt with exponential backoff\\n+ const backoffTime = Math.min(\\n+ 1000 * Math.pow(2, connectionAttempts),\\n+ 30000,\\n+ );\\n+ console.log(\\n+ `Waiting ${backoffTime / 1000}s before next connection attempt...`,\\n+ );\\n+ await new Promise((resolve) => setTimeout(resolve, backoffTime));\\n+\\n+ // Recursive call to retry\\n+ return ensureDbConnection();\\n+ }\\n+ }\\n+}\\n+\\n+const worker = new Worker(\\n+ \\\"request-progress\\\",\\n+ async (job) => {\\n+ const { requestId, type, userId, metadata } = job.data;\\n+ const { targetLocaleLabel } = metadata;\\n+ console.log(\\n+ `Starting progress tracking job ${job.id} for ${type} requestId: ${requestId}. userId: ${userId}`,\\n+ );\\n+\\n+ // Ensure DB connection is established\\n+ await ensureDbConnection();\\n+\\n+ const RequestProgress = (\\n+ await import(\\\"../app/api/models/request-progress.mjs\\\")\\n+ ).default;\\n+\\n+ // Check if already cancelled\\n+ const request = await retryDbOperation(() =>\\n+ RequestProgress.findOne({ requestId }),\\n+ );\\n+\\n+ if (request?.status === \\\"cancelled\\\") {\\n+ console.log(`Job ${job.id} was cancelled`);\\n+ return;\\n+ }\\n+\\n+ return new Promise((resolve, reject) => {\\n+ try {\\n+ let timeoutId;\\n+ let subscription;\\n+\\n+ // Add a periodic check for cancellation\\n+ const cancellationCheckInterval = setInterval(async () => {\\n+ try {\\n+ const updatedRequest = await retryDbOperation(() =>\\n+ RequestProgress.findOne({ requestId }),\\n+ );\\n+\\n+ if (updatedRequest?.status === \\\"cancelled\\\") {\\n+ console.log(`Job ${job.id} received cancellation`);\\n+ clearTimeout(timeoutId);\\n+ clearInterval(cancellationCheckInterval);\\n+ subscription?.unsubscribe();\\n+ resolve(); // Resolve without error since this is an expected cancellation\\n+ return;\\n+ }\\n+ } catch (error) {\\n+ console.error(\\\"Error in cancellation check:\\\", error);\\n+ // Don't terminate the job on cancellation check errors\\n+ }\\n+ }, 5000);\\n+\\n+ const resetIdleTimeout = () => {\\n+ clearTimeout(timeoutId);\\n+ timeoutId = setTimeout(\\n+ () => {\\n+ console.warn(\\n+ `Job ${job.id} timed out after 5 minutes of inactivity`,\\n+ );\\n+ subscription?.unsubscribe();\\n+ retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ status: \\\"failed\\\",\\n+ error: \\\"Operation timed out after 5 minutes of inactivity\\\",\\n+ },\\n+ ).exec(),\\n+ ).catch((err) =>\\n+ console.error(\\n+ \\\"Error updating progress on timeout:\\\",\\n+ err,\\n+ ),\\n+ );\\n+ reject(\\n+ new Error(\\n+ \\\"Operation timed out after 5 minutes of inactivity\\\",\\n+ ),\\n+ );\\n+ },\\n+ 5 * 60 * 1000,\\n+ );\\n+ };\\n+\\n+ // Start initial idle timeout\\n+ resetIdleTimeout();\\n+\\n+ subscription = client\\n+ .subscribe({\\n+ query: REQUEST_PROGRESS_SUBSCRIPTION,\\n+ variables: { requestIds: [requestId] },\\n+ })\\n+ .subscribe({\\n+ async next(x) {\\n+ try {\\n+ // Check for cancellation before processing updates\\n+ const currentRequest = await retryDbOperation(\\n+ () =>\\n+ RequestProgress.findOne({ requestId }),\\n+ );\\n+\\n+ if (currentRequest?.status === \\\"cancelled\\\") {\\n+ console.log(\\n+ `Job ${job.id} was cancelled during processing`,\\n+ );\\n+ clearTimeout(timeoutId);\\n+ clearInterval(cancellationCheckInterval);\\n+ subscription.unsubscribe();\\n+ resolve();\\n+ return;\\n+ }\\n+\\n+ const { data } = x;\\n+ // Reset idle timeout on each progress update\\n+ resetIdleTimeout();\\n+\\n+ let progress =\\n+ data?.requestProgress?.progress || 0;\\n+\\n+ // Check current progress and keep higher value\\n+ const currentDoc = await retryDbOperation(() =>\\n+ RequestProgress.findOne({ requestId }),\\n+ );\\n+\\n+ if (\\n+ currentDoc &&\\n+ progress < currentDoc.progress\\n+ ) {\\n+ console.log(\\n+ `Job ${job.id} maintaining higher progress value ${currentDoc.progress} instead of ${progress}`,\\n+ );\\n+ progress = currentDoc.progress;\\n+ }\\n+\\n+ let dataObject;\\n+\\n+ if (data?.requestProgress?.data) {\\n+ try {\\n+ dataObject = JSON.parse(\\n+ JSON.parse(\\n+ data?.requestProgress?.data,\\n+ ),\\n+ );\\n+ } catch (e) {\\n+ console.log(\\n+ \\\"Non-json data\\\",\\n+ data?.requestProgress?.data,\\n+ );\\n+ }\\n+ }\\n+\\n+ // Check for error field directly\\n+ if (data?.requestProgress?.error) {\\n+ const error = data.requestProgress.error;\\n+ console.error(\\n+ \\\"Error in request progress worker\\\",\\n+ error,\\n+ );\\n+\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ {\\n+ requestId,\\n+ },\\n+ {\\n+ status: \\\"failed\\\",\\n+ statusText: error,\\n+ },\\n+ ),\\n+ );\\n+\\n+ resolve(dataObject);\\n+ return;\\n+ }\\n+\\n+ // Update progress in database\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ progress,\\n+ statusText:\\n+ data?.requestProgress?.info,\\n+ data: dataObject,\\n+ status: \\\"in_progress\\\",\\n+ metadata: job.data.metadata,\\n+ },\\n+ ),\\n+ );\\n+\\n+ if (progress === 1) {\\n+ console.log(\\n+ `Job ${job.id} reached 100% completion`,\\n+ );\\n+\\n+ // If there's an error at 100%, mark as failed\\n+ if (data?.requestProgress?.error) {\\n+ console.error(\\n+ \\\"Error at 100% completion:\\\",\\n+ data.requestProgress.error,\\n+ );\\n+\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ status: \\\"failed\\\",\\n+ statusText:\\n+ data.requestProgress\\n+ .error,\\n+ },\\n+ ),\\n+ );\\n+ }\\n+ // If we have data, mark as completed with data\\n+ else if (dataObject) {\\n+ // Handle video translation completion if needed\\n+ if (\\n+ type === \\\"video-translate\\\" &&\\n+ userId\\n+ ) {\\n+ try {\\n+ const {\\n+ handleVideoTranslationCompletion,\\n+ } = await import(\\n+ \\\"../app/utils/video-state-handler.js\\\"\\n+ );\\n+ await handleVideoTranslationCompletion(\\n+ userId,\\n+ dataObject,\\n+ targetLocaleLabel,\\n+ );\\n+ } catch (error) {\\n+ console.error(\\n+ \\\"Error handling video translation completion:\\\",\\n+ error,\\n+ );\\n+ }\\n+ }\\n+\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ { status: \\\"completed\\\" },\\n+ ),\\n+ );\\n+ }\\n+ // Just mark as completed if we only have progress = 1\\n+ else {\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ { status: \\\"completed\\\" },\\n+ ),\\n+ );\\n+ }\\n+\\n+ clearTimeout(timeoutId);\\n+ subscription.unsubscribe();\\n+ resolve(dataObject);\\n+ return;\\n+ }\\n+\\n+ job.updateProgress(progress);\\n+ } catch (error) {\\n+ console.error(\\n+ \\\"Error in subscription next handler:\\\",\\n+ error,\\n+ );\\n+ // Don't fail the job on a single update error\\n+ }\\n+ },\\n+ async error(error) {\\n+ console.error(\\n+ `Job ${job.id} subscription error:`,\\n+ error,\\n+ );\\n+ clearTimeout(timeoutId);\\n+ clearInterval(cancellationCheckInterval);\\n+ subscription.unsubscribe();\\n+ try {\\n+ await retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ {\\n+ status: \\\"failed\\\",\\n+ statusText:\\n+ error.message ||\\n+ error.toString(),\\n+ },\\n+ ),\\n+ );\\n+ } catch (dbError) {\\n+ console.error(\\n+ \\\"Failed to update status on error:\\\",\\n+ dbError,\\n+ );\\n+ }\\n+ reject(error);\\n+ },\\n+ });\\n+ } catch (error) {\\n+ console.error(\\n+ `Failed to setup subscription for job ${job.id}:`,\\n+ error,\\n+ );\\n+ retryDbOperation(() =>\\n+ RequestProgress.findOneAndUpdate(\\n+ { requestId },\\n+ { status: \\\"failed\\\", error: error.message },\\n+ ).exec(),\\n+ ).catch((err) =>\\n+ console.error(\\n+ \\\"Error updating progress on setup failure:\\\",\\n+ err,\\n+ ),\\n+ );\\n+\\n+ reject(error);\\n+ }\\n+ });\\n+ },\\n+ {\\n+ connection,\\n+ autorun: false,\\n+ concurrency: 5,\\n+ stalledInterval: 300000, // 5 minutes in milliseconds\\n+ },\\n+);\\n+\\n+worker.on(\\\"completed\\\", (job, result) => {\\n+ console.log(`Job ${job.id} completed with result:`, result);\\n+});\\n+\\n+worker.on(\\\"failed\\\", (job, error) => {\\n+ console.error(`Job ${job.id} failed with error:`, error);\\n+});\\n+\\n+// Safely start the worker after ensuring database connection\\n+async function safelyStartWorker() {\\n+ try {\\n+ console.log(\\\"Ensuring database connection before starting worker...\\\");\\n+ await ensureDbConnection();\\n+\\n+ console.log(\\\"Starting request-progress worker...\\\");\\n+ worker.run();\\n+\\n+ console.log(\\\"Request-progress worker is now running\\\");\\n+ } catch (error) {\\n+ console.error(\\\"Failed to start worker:\\\", error);\\n+\\n+ // Try to restart after a delay if something goes wrong at startup\\n+ console.log(\\\"Will attempt to restart worker in 10 seconds...\\\");\\n+ setTimeout(safelyStartWorker, 10000);\\n+ }\\n+}\\n+\\n+// Export the safelyStartWorker function instead of the raw worker.run\\n+module.exports = {\\n+ run: safelyStartWorker,\\n+};\\ndiff --git a/jobs/worker.js b/jobs/worker.js\\nindex 7403a1b..a142f6c 100644\\n--- a/jobs/worker.js\\n+++ b/jobs/worker.js\\n@@ -9,6 +9,7 @@ const queueName = \\\"digest-build\\\";\\n const { REDIS_CONNECTION_STRING } = process.env;\\n const { Logger } = require(\\\"./logger.js\\\");\\n const { DIGEST_REBUILD_INTERVAL_HOURS = 4 } = process.env;\\n+const requestProgressWorker = require(\\\"./request-progress-worker\\\");\\n \\n const connection = new Redis(\\n REDIS_CONNECTION_STRING || \\\"redis://localhost:6379\\\",\\n@@ -81,29 +82,129 @@ worker.on(\\\"completed\\\", (job) => {\\n logger.log(\\\"job completed\\\");\\n });\\n \\n-worker.on(\\\"failed\\\", (job, err) => {\\n+worker.on(\\\"failed\\\", (job, error) => {\\n const logger = new Logger(job);\\n- logger.log(\\\"job failed\\\", err.message);\\n+ logger.log(\\\"job failed with error: \\\" + error.message);\\n });\\n \\n-worker.on(\\\"error\\\", (err) => {\\n- const logger = new Logger();\\n- logger.log(\\\"worker error\\\", err.message);\\n-});\\n+// Shared database connection management\\n+let dbInitialized = false;\\n+let connectionAttempts = 0;\\n+const MAX_CONNECTION_ATTEMPTS = 5;\\n \\n-console.log(\\\"starting worker\\\");\\n+// Ensure we have a database connection\\n+async function ensureDbConnection(forceReconnect = false) {\\n+ if (forceReconnect) {\\n+ dbInitialized = false;\\n+ }\\n \\n-(async () => {\\n- const connectToDatabase = (await import(\\\"../src/db.mjs\\\")).connectToDatabase;\\n- const closeDatabaseConnection = (await import(\\\"../src/db.mjs\\\"))\\n- .closeDatabaseConnection;\\n+ if (!dbInitialized) {\\n+ try {\\n+ connectionAttempts++;\\n+ console.log(\\n+ `Connecting to database (attempt ${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS})...`,\\n+ );\\n \\n- console.log(\\n- \\\"Connecting to database\\\",\\n- connectToDatabase,\\n- closeDatabaseConnection,\\n- );\\n- await connectToDatabase();\\n- console.log(\\\"Connected to database\\\");\\n-})();\\n-worker.run();\\n+ const connectToDatabase = (await import(\\\"../src/db.mjs\\\"))\\n+ .connectToDatabase;\\n+ await connectToDatabase();\\n+\\n+ // Give the connection a moment to fully establish\\n+ await new Promise((resolve) => setTimeout(resolve, 500));\\n+\\n+ // Get mongoose to check connection state\\n+ const mongoose = (await import(\\\"mongoose\\\")).default;\\n+\\n+ if (mongoose.connection && mongoose.connection.readyState === 1) {\\n+ console.log(\\\"Successfully connected to MongoDB database\\\");\\n+ dbInitialized = true;\\n+ connectionAttempts = 0;\\n+ } else {\\n+ throw new Error(\\n+ `Failed to establish MongoDB connection, current state: ${mongoose.connection ? mongoose.connection.readyState : \\\"unknown\\\"}`,\\n+ );\\n+ }\\n+ } catch (error) {\\n+ console.error(\\n+ `Failed to connect to database (attempt ${connectionAttempts}/${MAX_CONNECTION_ATTEMPTS}):`,\\n+ error,\\n+ );\\n+\\n+ if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) {\\n+ console.error(\\n+ \\\"Maximum connection attempts reached. Giving up.\\\",\\n+ );\\n+ throw new Error(\\n+ `Failed to connect to MongoDB after ${MAX_CONNECTION_ATTEMPTS} attempts: ${error.message}`,\\n+ );\\n+ }\\n+\\n+ // Wait before next attempt with exponential backoff\\n+ const backoffTime = Math.min(\\n+ 1000 * Math.pow(2, connectionAttempts),\\n+ 30000,\\n+ );\\n+ console.log(\\n+ `Waiting ${backoffTime / 1000}s before next connection attempt...`,\\n+ );\\n+ await new Promise((resolve) => setTimeout(resolve, backoffTime));\\n+\\n+ // Recursive call to retry\\n+ return ensureDbConnection();\\n+ }\\n+ }\\n+}\\n+\\n+// Graceful shutdown handler\\n+const cleanupAndExit = async () => {\\n+ console.log(\\\"Shutting down workers...\\\");\\n+\\n+ try {\\n+ // Stop processing new jobs\\n+ await worker.close();\\n+ console.log(\\\"Digest worker stopped\\\");\\n+\\n+ // Close database connection\\n+ if (dbInitialized) {\\n+ const closeDatabaseConnection = (await import(\\\"../src/db.mjs\\\"))\\n+ .closeDatabaseConnection;\\n+ await closeDatabaseConnection();\\n+ console.log(\\\"Database connection closed\\\");\\n+ }\\n+\\n+ console.log(\\\"Cleanup completed, exiting\\\");\\n+ process.exit(0);\\n+ } catch (error) {\\n+ console.error(\\\"Error during shutdown:\\\", error);\\n+ process.exit(1);\\n+ }\\n+};\\n+\\n+// Register shutdown handlers\\n+process.on(\\\"SIGTERM\\\", cleanupAndExit);\\n+process.on(\\\"SIGINT\\\", cleanupAndExit);\\n+\\n+// Safely start all workers after ensuring database connection\\n+async function startWorkers() {\\n+ try {\\n+ // Initialize database connection\\n+ console.log(\\\"Initializing connection to database...\\\");\\n+ await ensureDbConnection();\\n+\\n+ // Start workers\\n+ console.log(\\\"Starting workers...\\\");\\n+ await requestProgressWorker.run();\\n+ worker.run();\\n+\\n+ console.log(\\\"All workers are running\\\");\\n+ } catch (error) {\\n+ console.error(\\\"Failed to initialize:\\\", error);\\n+\\n+ // Try to restart after a delay\\n+ console.log(\\\"Will attempt to restart workers in 15 seconds...\\\");\\n+ setTimeout(startWorkers, 15000);\\n+ }\\n+}\\n+\\n+// Start the workers\\n+startWorkers();\\ndiff --git a/package-lock.json b/package-lock.json\\nindex f0d4495..3cd29a6 100644\\n--- a/package-lock.json\\n+++ b/package-lock.json\\n@@ -1,13 +1,14 @@\\n {\\n \\\"name\\\": \\\"labeeb\\\",\\n- \\\"version\\\": \\\"2.4.18\\\",\\n+ \\\"version\\\": \\\"2.5.0\\\",\\n \\\"lockfileVersion\\\": 3,\\n \\\"requires\\\": true,\\n \\\"packages\\\": {\\n \\\"\\\": {\\n \\\"name\\\": \\\"labeeb\\\",\\n- \\\"version\\\": \\\"2.4.18\\\",\\n+ \\\"version\\\": \\\"2.5.0\\\",\\n \\\"dependencies\\\": {\\n+ \\\"@aj-archipelago/subvibe\\\": \\\"^1.0.8\\\",\\n \\\"@amplitude/analytics-browser\\\": \\\"^2.3.2\\\",\\n \\\"@apollo/client\\\": \\\"^3.10.4\\\",\\n \\\"@apollo/experimental-nextjs-app-support\\\": \\\"^0.11.0\\\",\\n@@ -15,6 +16,7 @@\\n \\\"@hello-pangea/dnd\\\": \\\"^16.6.0\\\",\\n \\\"@heroicons/react\\\": \\\"^2.0.18\\\",\\n \\\"@radix-ui/react-accordion\\\": \\\"^1.1.2\\\",\\n+ \\\"@radix-ui/react-alert-dialog\\\": \\\"^1.1.4\\\",\\n \\\"@radix-ui/react-checkbox\\\": \\\"^1.1.2\\\",\\n \\\"@radix-ui/react-dialog\\\": \\\"^1.1.2\\\",\\n \\\"@radix-ui/react-dismissable-layer\\\": \\\"^1.1.1\\\",\\n@@ -22,7 +24,7 @@\\n \\\"@radix-ui/react-popover\\\": \\\"^1.0.7\\\",\\n \\\"@radix-ui/react-progress\\\": \\\"^1.0.3\\\",\\n \\\"@radix-ui/react-select\\\": \\\"^2.1.2\\\",\\n- \\\"@radix-ui/react-slot\\\": \\\"^1.0.2\\\",\\n+ \\\"@radix-ui/react-slot\\\": \\\"^1.1.1\\\",\\n \\\"@radix-ui/react-tabs\\\": \\\"^1.0.4\\\",\\n \\\"@radix-ui/react-toast\\\": \\\"^1.2.2\\\",\\n \\\"@radix-ui/react-toggle\\\": \\\"^1.0.3\\\",\\n@@ -69,7 +71,7 @@\\n \\\"react-filepond\\\": \\\"^7.1.2\\\",\\n \\\"react-i18next\\\": \\\"^12.2.0\\\",\\n \\\"react-icons\\\": \\\"^4.7.1\\\",\\n- \\\"react-intersection-observer\\\": \\\"^9.13.1\\\",\\n+ \\\"react-intersection-observer\\\": \\\"^9.15.1\\\",\\n \\\"react-markdown\\\": \\\"^9.0.1\\\",\\n \\\"react-monaco-editor\\\": \\\"^0.55.0\\\",\\n \\\"react-player\\\": \\\"^2.16.0\\\",\\n@@ -78,7 +80,6 @@\\n \\\"react-redux\\\": \\\"^8.0.5\\\",\\n \\\"react-router-dom\\\": \\\"^6.8.1\\\",\\n \\\"react-scripts\\\": \\\"5.0.1\\\",\\n- \\\"react-scroll-to-bottom\\\": \\\"^4.2.0\\\",\\n \\\"react-select\\\": \\\"^5.7.3\\\",\\n \\\"react-textarea-autosize\\\": \\\"^8.4.0\\\",\\n \\\"react-time-ago\\\": \\\"^7.3.1\\\",\\n@@ -99,6 +100,7 @@\\n \\\"xxhash-wasm\\\": \\\"^1.1.0\\\"\\n },\\n \\\"devDependencies\\\": {\\n+ \\\"@babel/plugin-proposal-private-property-in-object\\\": \\\"^7.21.11\\\",\\n \\\"@babel/preset-env\\\": \\\"^7.26.0\\\",\\n \\\"@babel/preset-react\\\": \\\"^7.26.3\\\",\\n \\\"@tailwindcss/forms\\\": \\\"^0.5.7\\\",\\n@@ -106,7 +108,9 @@\\n \\\"babel-jest\\\": \\\"^29.7.0\\\",\\n \\\"customize-cra\\\": \\\"^1.0.0\\\",\\n \\\"jest\\\": \\\"^29.7.0\\\",\\n+ \\\"jest-environment-jsdom\\\": \\\"^29.7.0\\\",\\n \\\"mongodb-memory-server\\\": \\\"^10.1.3\\\",\\n+ \\\"nodemon\\\": \\\"^3.1.9\\\",\\n \\\"postcss\\\": \\\"^8.4.31\\\",\\n \\\"prettier\\\": \\\"^3.2.2\\\",\\n \\\"react-app-rewired\\\": \\\"^2.2.1\\\",\\n@@ -129,6 +133,15 @@\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz\\\",\\n \\\"integrity\\\": \\\"sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==\\\"\\n },\\n+ \\\"node_modules/@aj-archipelago/subvibe\\\": {\\n+ \\\"version\\\": \\\"1.0.8\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@aj-archipelago/subvibe/-/subvibe-1.0.8.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-MlD6BeJBMqILXUe8qscmmZGorG60E6M7jXSLy6YJZxhQKu2Lir4qm7riDwRiE12PdL3QnNUnLx2jw9Re+mhJkA==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=14.0.0\\\"\\n+ }\\n+ },\\n \\\"node_modules/@alloc/quick-lru\\\": {\\n \\\"version\\\": \\\"5.2.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz\\\",\\n@@ -902,9 +915,18 @@\\n }\\n },\\n \\\"node_modules/@babel/plugin-proposal-private-property-in-object\\\": {\\n- \\\"version\\\": \\\"7.21.0-placeholder-for-preset-env.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==\\\",\\n+ \\\"version\\\": \\\"7.21.11\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==\\\",\\n+ \\\"deprecated\\\": \\\"This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/helper-annotate-as-pure\\\": \\\"^7.18.6\\\",\\n+ \\\"@babel/helper-create-class-features-plugin\\\": \\\"^7.21.0\\\",\\n+ \\\"@babel/helper-plugin-utils\\\": \\\"^7.20.2\\\",\\n+ \\\"@babel/plugin-syntax-private-property-in-object\\\": \\\"^7.14.5\\\"\\n+ },\\n \\\"engines\\\": {\\n \\\"node\\\": \\\">=6.9.0\\\"\\n },\\n@@ -2212,6 +2234,18 @@\\n \\\"@babel/core\\\": \\\"^7.4.0 || ^8.0.0-0 <8.0.0\\\"\\n }\\n },\\n+ \\\"node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object\\\": {\\n+ \\\"version\\\": \\\"7.21.0-placeholder-for-preset-env.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=6.9.0\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@babel/core\\\": \\\"^7.0.0-0\\\"\\n+ }\\n+ },\\n \\\"node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3\\\": {\\n \\\"version\\\": \\\"0.10.6\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz\\\",\\n@@ -2307,18 +2341,6 @@\\n \\\"node\\\": \\\">=6.9.0\\\"\\n }\\n },\\n- \\\"node_modules/@babel/runtime-corejs3\\\": {\\n- \\\"version\\\": \\\"7.22.6\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.22.6.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-M+37LLIRBTEVjktoJjbw4KVhupF0U/3PYUCbBwgAd9k17hoKhRu1n935QiG7Tuxv0LJOMrb2vuKEeYUlv0iyiw==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"core-js-pure\\\": \\\"^3.30.2\\\",\\n- \\\"regenerator-runtime\\\": \\\"^0.13.11\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=6.9.0\\\"\\n- }\\n- },\\n \\\"node_modules/@babel/runtime/node_modules/regenerator-runtime\\\": {\\n \\\"version\\\": \\\"0.14.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz\\\",\\n@@ -2683,26 +2705,6 @@\\n \\\"stylis\\\": \\\"4.2.0\\\"\\n }\\n },\\n- \\\"node_modules/@emotion/css\\\": {\\n- \\\"version\\\": \\\"11.1.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@emotion/css/-/css-11.1.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-RSQP59qtCNTf5NWD6xM08xsQdCZmVYnX/panPYvB6LQAPKQB6GL49Njf0EMbS3CyDtrlWsBcmqBtysFvfWT3rA==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@emotion/babel-plugin\\\": \\\"^11.0.0\\\",\\n- \\\"@emotion/cache\\\": \\\"^11.1.3\\\",\\n- \\\"@emotion/serialize\\\": \\\"^1.0.0\\\",\\n- \\\"@emotion/sheet\\\": \\\"^1.0.0\\\",\\n- \\\"@emotion/utils\\\": \\\"^1.0.0\\\"\\n- },\\n- \\\"peerDependencies\\\": {\\n- \\\"@babel/core\\\": \\\"^7.0.0\\\"\\n- },\\n- \\\"peerDependenciesMeta\\\": {\\n- \\\"@babel/core\\\": {\\n- \\\"optional\\\": true\\n- }\\n- }\\n- },\\n \\\"node_modules/@emotion/hash\\\": {\\n \\\"version\\\": \\\"0.9.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz\\\",\\n@@ -3882,66 +3884,6 @@\\n \\\"darwin\\\"\\n ]\\n },\\n- \\\"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64\\\": {\\n- \\\"version\\\": \\\"3.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"darwin\\\"\\n- ]\\n- },\\n- \\\"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm\\\": {\\n- \\\"version\\\": \\\"3.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm\\\"\\n- ],\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ]\\n- },\\n- \\\"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64\\\": {\\n- \\\"version\\\": \\\"3.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm64\\\"\\n- ],\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ]\\n- },\\n- \\\"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64\\\": {\\n- \\\"version\\\": \\\"3.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ]\\n- },\\n- \\\"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64\\\": {\\n- \\\"version\\\": \\\"3.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"win32\\\"\\n- ]\\n- },\\n \\\"node_modules/@next/env\\\": {\\n \\\"version\\\": \\\"14.0.3\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@next/env/-/env-14.0.3.tgz\\\",\\n@@ -4178,26 +4120,6 @@\\n \\\"@parcel/watcher-win32-x64\\\": \\\"2.5.0\\\"\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-android-arm64\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"android\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n- },\\n \\\"node_modules/@parcel/watcher-darwin-arm64\\\": {\\n \\\"version\\\": \\\"2.5.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz\\\",\\n@@ -4218,329 +4140,191 @@\\n \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-darwin-x64\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n+ \\\"node_modules/@parcel/watcher/node_modules/detect-libc\\\": {\\n+ \\\"version\\\": \\\"1.0.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==\\\",\\n \\\"dev\\\": true,\\n \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"darwin\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"bin\\\": {\\n+ \\\"detect-libc\\\": \\\"bin/detect-libc.js\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n- },\\n- \\\"node_modules/@parcel/watcher-freebsd-x64\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"freebsd\\\"\\n- ],\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n+ \\\"node\\\": \\\">=0.10\\\"\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-linux-arm-glibc\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm\\\"\\n- ],\\n+ \\\"node_modules/@parcel/watcher/node_modules/node-addon-api\\\": {\\n+ \\\"version\\\": \\\"7.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==\\\",\\n \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n+ \\\"optional\\\": true\\n },\\n- \\\"node_modules/@parcel/watcher-linux-arm-musl\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"node_modules/@pmmmwh/react-refresh-webpack-plugin\\\": {\\n+ \\\"version\\\": \\\"0.5.11\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"ansi-html-community\\\": \\\"^0.0.8\\\",\\n+ \\\"common-path-prefix\\\": \\\"^3.0.0\\\",\\n+ \\\"core-js-pure\\\": \\\"^3.23.3\\\",\\n+ \\\"error-stack-parser\\\": \\\"^2.0.6\\\",\\n+ \\\"find-up\\\": \\\"^5.0.0\\\",\\n+ \\\"html-entities\\\": \\\"^2.1.0\\\",\\n+ \\\"loader-utils\\\": \\\"^2.0.4\\\",\\n+ \\\"schema-utils\\\": \\\"^3.0.0\\\",\\n+ \\\"source-map\\\": \\\"^0.7.3\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n- },\\n- \\\"node_modules/@parcel/watcher-linux-arm64-glibc\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"node\\\": \\\">= 10.13\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/webpack\\\": \\\"4.x || 5.x\\\",\\n+ \\\"react-refresh\\\": \\\">=0.10.0 <1.0.0\\\",\\n+ \\\"sockjs-client\\\": \\\"^1.4.0\\\",\\n+ \\\"type-fest\\\": \\\">=0.17.0 <5.0.0\\\",\\n+ \\\"webpack\\\": \\\">=4.43.0 <6.0.0\\\",\\n+ \\\"webpack-dev-server\\\": \\\"3.x || 4.x\\\",\\n+ \\\"webpack-hot-middleware\\\": \\\"2.x\\\",\\n+ \\\"webpack-plugin-serve\\\": \\\"0.x || 1.x\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/webpack\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"sockjs-client\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"type-fest\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"webpack-dev-server\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"webpack-hot-middleware\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"webpack-plugin-serve\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-linux-arm64-musl\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n+ \\\"node_modules/@radix-ui/number\\\": {\\n+ \\\"version\\\": \\\"1.1.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==\\\"\\n+ },\\n+ \\\"node_modules/@radix-ui/primitive\\\": {\\n+ \\\"version\\\": \\\"1.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\"\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-linux-x64-glibc\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"node_modules/@radix-ui/react-accordion\\\": {\\n+ \\\"version\\\": \\\"1.1.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n+ \\\"@radix-ui/primitive\\\": \\\"1.0.1\\\",\\n+ \\\"@radix-ui/react-collapsible\\\": \\\"1.0.3\\\",\\n+ \\\"@radix-ui/react-collection\\\": \\\"1.0.3\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\",\\n+ \\\"@radix-ui/react-context\\\": \\\"1.0.1\\\",\\n+ \\\"@radix-ui/react-direction\\\": \\\"1.0.1\\\",\\n+ \\\"@radix-ui/react-id\\\": \\\"1.0.1\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"1.0.3\\\",\\n+ \\\"@radix-ui/react-use-controllable-state\\\": \\\"1.0.1\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n- },\\n- \\\"node_modules/@parcel/watcher-linux-x64-musl\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"linux\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"@types/react-dom\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\",\\n+ \\\"react-dom\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"@types/react-dom\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-win32-arm64\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==\\\",\\n- \\\"cpu\\\": [\\n- \\\"arm64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"win32\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"node_modules/@radix-ui/react-alert-dialog\\\": {\\n+ \\\"version\\\": \\\"1.1.4\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@radix-ui/primitive\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-context\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-dialog\\\": \\\"1.1.4\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"2.0.1\\\",\\n+ \\\"@radix-ui/react-slot\\\": \\\"1.1.1\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n- },\\n- \\\"node_modules/@parcel/watcher-win32-ia32\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==\\\",\\n- \\\"cpu\\\": [\\n- \\\"ia32\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"win32\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"@types/react-dom\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\",\\n+ \\\"react-dom\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"@types/react-dom\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n- \\\"node_modules/@parcel/watcher-win32-x64\\\": {\\n- \\\"version\\\": \\\"2.5.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==\\\",\\n- \\\"cpu\\\": [\\n- \\\"x64\\\"\\n- ],\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"os\\\": [\\n- \\\"win32\\\"\\n- ],\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.0.0\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/parcel\\\"\\n- }\\n+ \\\"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive\\\": {\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==\\\"\\n },\\n- \\\"node_modules/@parcel/watcher/node_modules/detect-libc\\\": {\\n- \\\"version\\\": \\\"1.0.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==\\\",\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true,\\n- \\\"bin\\\": {\\n- \\\"detect-libc\\\": \\\"bin/detect-libc.js\\\"\\n+ \\\"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs\\\": {\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==\\\",\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=0.10\\\"\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n- \\\"node_modules/@parcel/watcher/node_modules/node-addon-api\\\": {\\n- \\\"version\\\": \\\"7.1.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==\\\",\\n- \\\"dev\\\": true,\\n- \\\"optional\\\": true\\n- },\\n- \\\"node_modules/@pmmmwh/react-refresh-webpack-plugin\\\": {\\n- \\\"version\\\": \\\"0.5.11\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"ansi-html-community\\\": \\\"^0.0.8\\\",\\n- \\\"common-path-prefix\\\": \\\"^3.0.0\\\",\\n- \\\"core-js-pure\\\": \\\"^3.23.3\\\",\\n- \\\"error-stack-parser\\\": \\\"^2.0.6\\\",\\n- \\\"find-up\\\": \\\"^5.0.0\\\",\\n- \\\"html-entities\\\": \\\"^2.1.0\\\",\\n- \\\"loader-utils\\\": \\\"^2.0.4\\\",\\n- \\\"schema-utils\\\": \\\"^3.0.0\\\",\\n- \\\"source-map\\\": \\\"^0.7.3\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 10.13\\\"\\n- },\\n+ \\\"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context\\\": {\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==\\\",\\n \\\"peerDependencies\\\": {\\n- \\\"@types/webpack\\\": \\\"4.x || 5.x\\\",\\n- \\\"react-refresh\\\": \\\">=0.10.0 <1.0.0\\\",\\n- \\\"sockjs-client\\\": \\\"^1.4.0\\\",\\n- \\\"type-fest\\\": \\\">=0.17.0 <5.0.0\\\",\\n- \\\"webpack\\\": \\\">=4.43.0 <6.0.0\\\",\\n- \\\"webpack-dev-server\\\": \\\"3.x || 4.x\\\",\\n- \\\"webpack-hot-middleware\\\": \\\"2.x\\\",\\n- \\\"webpack-plugin-serve\\\": \\\"0.x || 1.x\\\"\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n- \\\"@types/webpack\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"sockjs-client\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"type-fest\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"webpack-dev-server\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"webpack-hot-middleware\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"webpack-plugin-serve\\\": {\\n+ \\\"@types/react\\\": {\\n \\\"optional\\\": true\\n }\\n }\\n },\\n- \\\"node_modules/@radix-ui/number\\\": {\\n- \\\"version\\\": \\\"1.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==\\\"\\n- },\\n- \\\"node_modules/@radix-ui/primitive\\\": {\\n- \\\"version\\\": \\\"1.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@babel/runtime\\\": \\\"^7.13.10\\\"\\n- }\\n- },\\n- \\\"node_modules/@radix-ui/react-accordion\\\": {\\n- \\\"version\\\": \\\"1.1.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==\\\",\\n+ \\\"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive\\\": {\\n+ \\\"version\\\": \\\"2.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n- \\\"@radix-ui/primitive\\\": \\\"1.0.1\\\",\\n- \\\"@radix-ui/react-collapsible\\\": \\\"1.0.3\\\",\\n- \\\"@radix-ui/react-collection\\\": \\\"1.0.3\\\",\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\",\\n- \\\"@radix-ui/react-context\\\": \\\"1.0.1\\\",\\n- \\\"@radix-ui/react-direction\\\": \\\"1.0.1\\\",\\n- \\\"@radix-ui/react-id\\\": \\\"1.0.1\\\",\\n- \\\"@radix-ui/react-primitive\\\": \\\"1.0.3\\\",\\n- \\\"@radix-ui/react-use-controllable-state\\\": \\\"1.0.1\\\"\\n+ \\\"@radix-ui/react-slot\\\": \\\"1.1.1\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"@types/react\\\": \\\"*\\\",\\n \\\"@types/react-dom\\\": \\\"*\\\",\\n- \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\",\\n- \\\"react-dom\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\",\\n+ \\\"react-dom\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -4816,6 +4600,24 @@\\n }\\n }\\n },\\n+ \\\"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot\\\": {\\n+ \\\"version\\\": \\\"1.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n \\\"node_modules/@radix-ui/react-compose-refs\\\": {\\n \\\"version\\\": \\\"1.0.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz\\\",\\n@@ -4851,24 +4653,24 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog\\\": {\\n- \\\"version\\\": \\\"1.1.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==\\\",\\n+ \\\"version\\\": \\\"1.1.4\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.4.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@radix-ui/primitive\\\": \\\"1.1.0\\\",\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.0\\\",\\n+ \\\"@radix-ui/primitive\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\",\\n \\\"@radix-ui/react-context\\\": \\\"1.1.1\\\",\\n- \\\"@radix-ui/react-dismissable-layer\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-dismissable-layer\\\": \\\"1.1.3\\\",\\n \\\"@radix-ui/react-focus-guards\\\": \\\"1.1.1\\\",\\n- \\\"@radix-ui/react-focus-scope\\\": \\\"1.1.0\\\",\\n+ \\\"@radix-ui/react-focus-scope\\\": \\\"1.1.1\\\",\\n \\\"@radix-ui/react-id\\\": \\\"1.1.0\\\",\\n- \\\"@radix-ui/react-portal\\\": \\\"1.1.2\\\",\\n- \\\"@radix-ui/react-presence\\\": \\\"1.1.1\\\",\\n- \\\"@radix-ui/react-primitive\\\": \\\"2.0.0\\\",\\n- \\\"@radix-ui/react-slot\\\": \\\"1.1.0\\\",\\n+ \\\"@radix-ui/react-portal\\\": \\\"1.1.3\\\",\\n+ \\\"@radix-ui/react-presence\\\": \\\"1.1.2\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"2.0.1\\\",\\n+ \\\"@radix-ui/react-slot\\\": \\\"1.1.1\\\",\\n \\\"@radix-ui/react-use-controllable-state\\\": \\\"1.1.0\\\",\\n \\\"aria-hidden\\\": \\\"^1.1.1\\\",\\n- \\\"react-remove-scroll\\\": \\\"2.6.0\\\"\\n+ \\\"react-remove-scroll\\\": \\\"^2.6.1\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"@types/react\\\": \\\"*\\\",\\n@@ -4886,14 +4688,14 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive\\\": {\\n- \\\"version\\\": \\\"1.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==\\\"\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==\\\"\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs\\\": {\\n- \\\"version\\\": \\\"1.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==\\\",\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==\\\",\\n \\\"peerDependencies\\\": {\\n \\\"@types/react\\\": \\\"*\\\",\\n \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n@@ -4918,6 +4720,32 @@\\n }\\n }\\n },\\n+ \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer\\\": {\\n+ \\\"version\\\": \\\"1.1.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@radix-ui/primitive\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"2.0.1\\\",\\n+ \\\"@radix-ui/react-use-callback-ref\\\": \\\"1.1.0\\\",\\n+ \\\"@radix-ui/react-use-escape-keydown\\\": \\\"1.1.0\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"@types/react-dom\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\",\\n+ \\\"react-dom\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"@types/react-dom\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards\\\": {\\n \\\"version\\\": \\\"1.1.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz\\\",\\n@@ -4933,12 +4761,12 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope\\\": {\\n- \\\"version\\\": \\\"1.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==\\\",\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.0\\\",\\n- \\\"@radix-ui/react-primitive\\\": \\\"2.0.0\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"2.0.1\\\",\\n \\\"@radix-ui/react-use-callback-ref\\\": \\\"1.1.0\\\"\\n },\\n \\\"peerDependencies\\\": {\\n@@ -4974,11 +4802,11 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal\\\": {\\n- \\\"version\\\": \\\"1.1.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==\\\",\\n+ \\\"version\\\": \\\"1.1.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@radix-ui/react-primitive\\\": \\\"2.0.0\\\",\\n+ \\\"@radix-ui/react-primitive\\\": \\\"2.0.1\\\",\\n \\\"@radix-ui/react-use-layout-effect\\\": \\\"1.1.0\\\"\\n },\\n \\\"peerDependencies\\\": {\\n@@ -4997,11 +4825,11 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence\\\": {\\n- \\\"version\\\": \\\"1.1.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==\\\",\\n+ \\\"version\\\": \\\"1.1.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.0\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\",\\n \\\"@radix-ui/react-use-layout-effect\\\": \\\"1.1.0\\\"\\n },\\n \\\"peerDependencies\\\": {\\n@@ -5020,11 +4848,11 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive\\\": {\\n- \\\"version\\\": \\\"2.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==\\\",\\n+ \\\"version\\\": \\\"2.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@radix-ui/react-slot\\\": \\\"1.1.0\\\"\\n+ \\\"@radix-ui/react-slot\\\": \\\"1.1.1\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"@types/react\\\": \\\"*\\\",\\n@@ -5041,23 +4869,6 @@\\n }\\n }\\n },\\n- \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot\\\": {\\n- \\\"version\\\": \\\"1.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.0\\\"\\n- },\\n- \\\"peerDependencies\\\": {\\n- \\\"@types/react\\\": \\\"*\\\",\\n- \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n- },\\n- \\\"peerDependenciesMeta\\\": {\\n- \\\"@types/react\\\": {\\n- \\\"optional\\\": true\\n- }\\n- }\\n- },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref\\\": {\\n \\\"version\\\": \\\"1.1.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz\\\",\\n@@ -5104,22 +4915,22 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll\\\": {\\n- \\\"version\\\": \\\"2.6.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==\\\",\\n+ \\\"version\\\": \\\"2.6.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==\\\",\\n \\\"dependencies\\\": {\\n- \\\"react-remove-scroll-bar\\\": \\\"^2.3.6\\\",\\n+ \\\"react-remove-scroll-bar\\\": \\\"^2.3.7\\\",\\n \\\"react-style-singleton\\\": \\\"^2.2.1\\\",\\n \\\"tslib\\\": \\\"^2.1.0\\\",\\n- \\\"use-callback-ref\\\": \\\"^1.3.0\\\",\\n+ \\\"use-callback-ref\\\": \\\"^1.3.3\\\",\\n \\\"use-sidecar\\\": \\\"^1.1.2\\\"\\n },\\n \\\"engines\\\": {\\n \\\"node\\\": \\\">=10\\\"\\n },\\n \\\"peerDependencies\\\": {\\n- \\\"@types/react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\",\\n- \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\"\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -5996,6 +5807,24 @@\\n }\\n }\\n },\\n+ \\\"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot\\\": {\\n+ \\\"version\\\": \\\"1.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n \\\"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-escape-keydown\\\": {\\n \\\"version\\\": \\\"1.0.3\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz\\\",\\n@@ -6116,6 +5945,24 @@\\n }\\n }\\n },\\n+ \\\"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot\\\": {\\n+ \\\"version\\\": \\\"1.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n \\\"node_modules/@radix-ui/react-progress\\\": {\\n \\\"version\\\": \\\"1.0.3\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.0.3.tgz\\\",\\n@@ -6614,16 +6461,29 @@\\n }\\n },\\n \\\"node_modules/@radix-ui/react-slot\\\": {\\n- \\\"version\\\": \\\"1.0.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==\\\",\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==\\\",\\n \\\"dependencies\\\": {\\n- \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n- \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\"\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.1.1\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"@types/react\\\": \\\"*\\\",\\n- \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n+ \\\"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs\\\": {\\n+ \\\"version\\\": \\\"1.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==\\\",\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -7258,6 +7118,24 @@\\n }\\n }\\n },\\n+ \\\"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot\\\": {\\n+ \\\"version\\\": \\\"1.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@babel/runtime\\\": \\\"^7.13.10\\\",\\n+ \\\"@radix-ui/react-compose-refs\\\": \\\"1.0.1\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8 || ^17.0 || ^18.0\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"@types/react\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n \\\"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown\\\": {\\n \\\"version\\\": \\\"1.0.3\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz\\\",\\n@@ -7996,12 +7874,13 @@\\n }\\n },\\n \\\"node_modules/@tootallnate/once\\\": {\\n- \\\"version\\\": \\\"1.1.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==\\\",\\n+ \\\"version\\\": \\\"2.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 6\\\"\\n+ \\\"node\\\": \\\">= 10\\\"\\n }\\n },\\n \\\"node_modules/@trysound/sax\\\": {\\n@@ -8255,6 +8134,18 @@\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz\\\",\\n \\\"integrity\\\": \\\"sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==\\\"\\n },\\n+ \\\"node_modules/@types/jsdom\\\": {\\n+ \\\"version\\\": \\\"20.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@types/node\\\": \\\"*\\\",\\n+ \\\"@types/tough-cookie\\\": \\\"*\\\",\\n+ \\\"parse5\\\": \\\"^7.0.0\\\"\\n+ }\\n+ },\\n \\\"node_modules/@types/json-schema\\\": {\\n \\\"version\\\": \\\"7.0.15\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz\\\",\\n@@ -8437,6 +8328,13 @@\\n \\\"@types/jest\\\": \\\"*\\\"\\n }\\n },\\n+ \\\"node_modules/@types/tough-cookie\\\": {\\n+ \\\"version\\\": \\\"4.0.5\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n \\\"node_modules/@types/trusted-types\\\": {\\n \\\"version\\\": \\\"2.0.7\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz\\\",\\n@@ -8935,25 +8833,14 @@\\n }\\n },\\n \\\"node_modules/acorn-globals\\\": {\\n- \\\"version\\\": \\\"6.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==\\\",\\n+ \\\"version\\\": \\\"7.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"acorn\\\": \\\"^7.1.1\\\",\\n- \\\"acorn-walk\\\": \\\"^7.1.1\\\"\\n- }\\n- },\\n- \\\"node_modules/acorn-globals/node_modules/acorn\\\": {\\n- \\\"version\\\": \\\"7.4.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"bin\\\": {\\n- \\\"acorn\\\": \\\"bin/acorn\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=0.4.0\\\"\\n+ \\\"acorn\\\": \\\"^8.1.0\\\",\\n+ \\\"acorn-walk\\\": \\\"^8.0.2\\\"\\n }\\n },\\n \\\"node_modules/acorn-import-assertions\\\": {\\n@@ -8973,10 +8860,14 @@\\n }\\n },\\n \\\"node_modules/acorn-walk\\\": {\\n- \\\"version\\\": \\\"7.2.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==\\\",\\n+ \\\"version\\\": \\\"8.3.4\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"acorn\\\": \\\"^8.11.0\\\"\\n+ },\\n \\\"engines\\\": {\\n \\\"node\\\": \\\">=0.4.0\\\"\\n }\\n@@ -11223,9 +11114,10 @@\\n }\\n },\\n \\\"node_modules/cssom\\\": {\\n- \\\"version\\\": \\\"0.4.4\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==\\\",\\n+ \\\"version\\\": \\\"0.5.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\"\\n },\\n \\\"node_modules/cssstyle\\\": {\\n@@ -11266,17 +11158,18 @@\\n \\\"integrity\\\": \\\"sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==\\\"\\n },\\n \\\"node_modules/data-urls\\\": {\\n- \\\"version\\\": \\\"2.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==\\\",\\n+ \\\"version\\\": \\\"3.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"abab\\\": \\\"^2.0.3\\\",\\n- \\\"whatwg-mimetype\\\": \\\"^2.3.0\\\",\\n- \\\"whatwg-url\\\": \\\"^8.0.0\\\"\\n+ \\\"abab\\\": \\\"^2.0.6\\\",\\n+ \\\"whatwg-mimetype\\\": \\\"^3.0.0\\\",\\n+ \\\"whatwg-url\\\": \\\"^11.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/dayjs\\\": {\\n@@ -11302,9 +11195,9 @@\\n }\\n },\\n \\\"node_modules/decimal.js\\\": {\\n- \\\"version\\\": \\\"10.4.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==\\\",\\n+ \\\"version\\\": \\\"10.5.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==\\\",\\n \\\"license\\\": \\\"MIT\\\"\\n },\\n \\\"node_modules/decode-named-character-reference\\\": {\\n@@ -11654,25 +11547,17 @@\\n ]\\n },\\n \\\"node_modules/domexception\\\": {\\n- \\\"version\\\": \\\"2.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==\\\",\\n+ \\\"version\\\": \\\"4.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==\\\",\\n \\\"deprecated\\\": \\\"Use your platform's native DOMException instead\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"webidl-conversions\\\": \\\"^5.0.0\\\"\\n+ \\\"webidl-conversions\\\": \\\"^7.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=8\\\"\\n- }\\n- },\\n- \\\"node_modules/domexception/node_modules/webidl-conversions\\\": {\\n- \\\"version\\\": \\\"5.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==\\\",\\n- \\\"license\\\": \\\"BSD-2-Clause\\\",\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=8\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/domhandler\\\": {\\n@@ -13927,15 +13812,16 @@\\n }\\n },\\n \\\"node_modules/html-encoding-sniffer\\\": {\\n- \\\"version\\\": \\\"2.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==\\\",\\n+ \\\"version\\\": \\\"3.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"whatwg-encoding\\\": \\\"^1.0.5\\\"\\n+ \\\"whatwg-encoding\\\": \\\"^2.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/html-entities\\\": {\\n@@ -14096,12 +13982,13 @@\\n }\\n },\\n \\\"node_modules/http-proxy-agent\\\": {\\n- \\\"version\\\": \\\"4.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==\\\",\\n+ \\\"version\\\": \\\"5.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"@tootallnate/once\\\": \\\"1\\\",\\n+ \\\"@tootallnate/once\\\": \\\"2\\\",\\n \\\"agent-base\\\": \\\"6\\\",\\n \\\"debug\\\": \\\"4\\\"\\n },\\n@@ -14264,6 +14151,13 @@\\n \\\"node\\\": \\\">= 4\\\"\\n }\\n },\\n+ \\\"node_modules/ignore-by-default\\\": {\\n+ \\\"version\\\": \\\"1.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"ISC\\\"\\n+ },\\n \\\"node_modules/immer\\\": {\\n \\\"version\\\": \\\"9.0.21\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/immer/-/immer-9.0.21.tgz\\\",\\n@@ -14374,14 +14268,6 @@\\n \\\"node\\\": \\\">= 0.4\\\"\\n }\\n },\\n- \\\"node_modules/invariant\\\": {\\n- \\\"version\\\": \\\"2.2.4\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"loose-envify\\\": \\\"^1.0.0\\\"\\n- }\\n- },\\n \\\"node_modules/ioredis\\\": {\\n \\\"version\\\": \\\"5.4.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz\\\",\\n@@ -15643,162 +15529,31 @@\\n \\\"license\\\": \\\"MIT\\\"\\n },\\n \\\"node_modules/jest-environment-jsdom\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@jest/environment\\\": \\\"^27.5.1\\\",\\n- \\\"@jest/fake-timers\\\": \\\"^27.5.1\\\",\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@types/node\\\": \\\"*\\\",\\n- \\\"jest-mock\\\": \\\"^27.5.1\\\",\\n- \\\"jest-util\\\": \\\"^27.5.1\\\",\\n- \\\"jsdom\\\": \\\"^16.6.0\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@jest/environment\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@jest/fake-timers\\\": \\\"^27.5.1\\\",\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@types/node\\\": \\\"*\\\",\\n- \\\"jest-mock\\\": \\\"^27.5.1\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@jest/fake-timers\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@sinonjs/fake-timers\\\": \\\"^8.0.1\\\",\\n- \\\"@types/node\\\": \\\"*\\\",\\n- \\\"jest-message-util\\\": \\\"^27.5.1\\\",\\n- \\\"jest-mock\\\": \\\"^27.5.1\\\",\\n- \\\"jest-util\\\": \\\"^27.5.1\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@jest/types\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==\\\",\\n+ \\\"version\\\": \\\"29.7.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"@types/istanbul-lib-coverage\\\": \\\"^2.0.0\\\",\\n- \\\"@types/istanbul-reports\\\": \\\"^3.0.0\\\",\\n+ \\\"@jest/environment\\\": \\\"^29.7.0\\\",\\n+ \\\"@jest/fake-timers\\\": \\\"^29.7.0\\\",\\n+ \\\"@jest/types\\\": \\\"^29.6.3\\\",\\n+ \\\"@types/jsdom\\\": \\\"^20.0.0\\\",\\n \\\"@types/node\\\": \\\"*\\\",\\n- \\\"@types/yargs\\\": \\\"^16.0.0\\\",\\n- \\\"chalk\\\": \\\"^4.0.0\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@sinonjs/commons\\\": {\\n- \\\"version\\\": \\\"1.8.6\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==\\\",\\n- \\\"license\\\": \\\"BSD-3-Clause\\\",\\n- \\\"dependencies\\\": {\\n- \\\"type-detect\\\": \\\"4.0.8\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers\\\": {\\n- \\\"version\\\": \\\"8.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==\\\",\\n- \\\"license\\\": \\\"BSD-3-Clause\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@sinonjs/commons\\\": \\\"^1.7.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/@types/yargs\\\": {\\n- \\\"version\\\": \\\"16.0.9\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-tHhzvkFXZQeTECenFoRljLBYPZJ7jAVxqqtEI0qTLOmuultnFp4I9yKE17vTuhf7BkhCu7I4XuemPgikDVuYqA==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@types/yargs-parser\\\": \\\"*\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/chalk\\\": {\\n- \\\"version\\\": \\\"4.1.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"ansi-styles\\\": \\\"^4.1.0\\\",\\n- \\\"supports-color\\\": \\\"^7.1.0\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n- },\\n- \\\"funding\\\": {\\n- \\\"url\\\": \\\"https://github.com/chalk/chalk?sponsor=1\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/jest-message-util\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@babel/code-frame\\\": \\\"^7.12.13\\\",\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@types/stack-utils\\\": \\\"^2.0.0\\\",\\n- \\\"chalk\\\": \\\"^4.0.0\\\",\\n- \\\"graceful-fs\\\": \\\"^4.2.9\\\",\\n- \\\"micromatch\\\": \\\"^4.0.4\\\",\\n- \\\"pretty-format\\\": \\\"^27.5.1\\\",\\n- \\\"slash\\\": \\\"^3.0.0\\\",\\n- \\\"stack-utils\\\": \\\"^2.0.3\\\"\\n+ \\\"jest-mock\\\": \\\"^29.7.0\\\",\\n+ \\\"jest-util\\\": \\\"^29.7.0\\\",\\n+ \\\"jsdom\\\": \\\"^20.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/jest-mock\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@types/node\\\": \\\"*\\\"\\n+ \\\"node\\\": \\\"^14.15.0 || ^16.10.0 || >=18.0.0\\\"\\n },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n- }\\n- },\\n- \\\"node_modules/jest-environment-jsdom/node_modules/jest-util\\\": {\\n- \\\"version\\\": \\\"27.5.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n- \\\"@types/node\\\": \\\"*\\\",\\n- \\\"chalk\\\": \\\"^4.0.0\\\",\\n- \\\"ci-info\\\": \\\"^3.2.0\\\",\\n- \\\"graceful-fs\\\": \\\"^4.2.9\\\",\\n- \\\"picomatch\\\": \\\"^2.2.3\\\"\\n+ \\\"peerDependencies\\\": {\\n+ \\\"canvas\\\": \\\"^2.5.0\\\"\\n },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"canvas\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n \\\"node_modules/jest-environment-node\\\": {\\n@@ -17683,41 +17438,41 @@\\n }\\n },\\n \\\"node_modules/jsdom\\\": {\\n- \\\"version\\\": \\\"16.7.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==\\\",\\n+ \\\"version\\\": \\\"20.0.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"abab\\\": \\\"^2.0.5\\\",\\n- \\\"acorn\\\": \\\"^8.2.4\\\",\\n- \\\"acorn-globals\\\": \\\"^6.0.0\\\",\\n- \\\"cssom\\\": \\\"^0.4.4\\\",\\n+ \\\"abab\\\": \\\"^2.0.6\\\",\\n+ \\\"acorn\\\": \\\"^8.8.1\\\",\\n+ \\\"acorn-globals\\\": \\\"^7.0.0\\\",\\n+ \\\"cssom\\\": \\\"^0.5.0\\\",\\n \\\"cssstyle\\\": \\\"^2.3.0\\\",\\n- \\\"data-urls\\\": \\\"^2.0.0\\\",\\n- \\\"decimal.js\\\": \\\"^10.2.1\\\",\\n- \\\"domexception\\\": \\\"^2.0.1\\\",\\n+ \\\"data-urls\\\": \\\"^3.0.2\\\",\\n+ \\\"decimal.js\\\": \\\"^10.4.2\\\",\\n+ \\\"domexception\\\": \\\"^4.0.0\\\",\\n \\\"escodegen\\\": \\\"^2.0.0\\\",\\n- \\\"form-data\\\": \\\"^3.0.0\\\",\\n- \\\"html-encoding-sniffer\\\": \\\"^2.0.1\\\",\\n- \\\"http-proxy-agent\\\": \\\"^4.0.1\\\",\\n- \\\"https-proxy-agent\\\": \\\"^5.0.0\\\",\\n+ \\\"form-data\\\": \\\"^4.0.0\\\",\\n+ \\\"html-encoding-sniffer\\\": \\\"^3.0.0\\\",\\n+ \\\"http-proxy-agent\\\": \\\"^5.0.0\\\",\\n+ \\\"https-proxy-agent\\\": \\\"^5.0.1\\\",\\n \\\"is-potential-custom-element-name\\\": \\\"^1.0.1\\\",\\n- \\\"nwsapi\\\": \\\"^2.2.0\\\",\\n- \\\"parse5\\\": \\\"6.0.1\\\",\\n- \\\"saxes\\\": \\\"^5.0.1\\\",\\n+ \\\"nwsapi\\\": \\\"^2.2.2\\\",\\n+ \\\"parse5\\\": \\\"^7.1.1\\\",\\n+ \\\"saxes\\\": \\\"^6.0.0\\\",\\n \\\"symbol-tree\\\": \\\"^3.2.4\\\",\\n- \\\"tough-cookie\\\": \\\"^4.0.0\\\",\\n- \\\"w3c-hr-time\\\": \\\"^1.0.2\\\",\\n- \\\"w3c-xmlserializer\\\": \\\"^2.0.0\\\",\\n- \\\"webidl-conversions\\\": \\\"^6.1.0\\\",\\n- \\\"whatwg-encoding\\\": \\\"^1.0.5\\\",\\n- \\\"whatwg-mimetype\\\": \\\"^2.3.0\\\",\\n- \\\"whatwg-url\\\": \\\"^8.5.0\\\",\\n- \\\"ws\\\": \\\"^7.4.6\\\",\\n- \\\"xml-name-validator\\\": \\\"^3.0.0\\\"\\n+ \\\"tough-cookie\\\": \\\"^4.1.2\\\",\\n+ \\\"w3c-xmlserializer\\\": \\\"^4.0.0\\\",\\n+ \\\"webidl-conversions\\\": \\\"^7.0.0\\\",\\n+ \\\"whatwg-encoding\\\": \\\"^2.0.0\\\",\\n+ \\\"whatwg-mimetype\\\": \\\"^3.0.0\\\",\\n+ \\\"whatwg-url\\\": \\\"^11.0.0\\\",\\n+ \\\"ws\\\": \\\"^8.11.0\\\",\\n+ \\\"xml-name-validator\\\": \\\"^4.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=14\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"canvas\\\": \\\"^2.5.0\\\"\\n@@ -17728,26 +17483,6 @@\\n }\\n }\\n },\\n- \\\"node_modules/jsdom/node_modules/form-data\\\": {\\n- \\\"version\\\": \\\"3.0.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"asynckit\\\": \\\"^0.4.0\\\",\\n- \\\"combined-stream\\\": \\\"^1.0.8\\\",\\n- \\\"mime-types\\\": \\\"^2.1.12\\\"\\n- },\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">= 6\\\"\\n- }\\n- },\\n- \\\"node_modules/jsdom/node_modules/parse5\\\": {\\n- \\\"version\\\": \\\"6.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==\\\",\\n- \\\"license\\\": \\\"MIT\\\"\\n- },\\n \\\"node_modules/jsesc\\\": {\\n \\\"version\\\": \\\"3.1.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz\\\",\\n@@ -18167,11 +17902,6 @@\\n \\\"node\\\": \\\">= 18\\\"\\n }\\n },\\n- \\\"node_modules/math-random\\\": {\\n- \\\"version\\\": \\\"2.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/math-random/-/math-random-2.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-oIEbWiVDxDpl5tIF4S6zYS9JExhh3bun3uLb3YAinHPTlRtW4g1S66LtJrJ4Npq8dgIa8CLK5iPVah5n4n0s2w==\\\"\\n- },\\n \\\"node_modules/mdast-util-directive\\\": {\\n \\\"version\\\": \\\"3.0.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz\\\",\\n@@ -19365,14 +19095,6 @@\\n \\\"node\\\": \\\">=14\\\"\\n }\\n },\\n- \\\"node_modules/mongodb-connection-string-url/node_modules/webidl-conversions\\\": {\\n- \\\"version\\\": \\\"7.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==\\\",\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=12\\\"\\n- }\\n- },\\n \\\"node_modules/mongodb-connection-string-url/node_modules/whatwg-url\\\": {\\n \\\"version\\\": \\\"13.0.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz\\\",\\n@@ -19789,6 +19511,58 @@\\n \\\"integrity\\\": \\\"sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==\\\",\\n \\\"license\\\": \\\"MIT\\\"\\n },\\n+ \\\"node_modules/nodemon\\\": {\\n+ \\\"version\\\": \\\"3.1.9\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"chokidar\\\": \\\"^3.5.2\\\",\\n+ \\\"debug\\\": \\\"^4\\\",\\n+ \\\"ignore-by-default\\\": \\\"^1.0.1\\\",\\n+ \\\"minimatch\\\": \\\"^3.1.2\\\",\\n+ \\\"pstree.remy\\\": \\\"^1.1.8\\\",\\n+ \\\"semver\\\": \\\"^7.5.3\\\",\\n+ \\\"simple-update-notifier\\\": \\\"^2.0.0\\\",\\n+ \\\"supports-color\\\": \\\"^5.5.0\\\",\\n+ \\\"touch\\\": \\\"^3.1.0\\\",\\n+ \\\"undefsafe\\\": \\\"^2.0.5\\\"\\n+ },\\n+ \\\"bin\\\": {\\n+ \\\"nodemon\\\": \\\"bin/nodemon.js\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ },\\n+ \\\"funding\\\": {\\n+ \\\"type\\\": \\\"opencollective\\\",\\n+ \\\"url\\\": \\\"https://opencollective.com/nodemon\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/nodemon/node_modules/has-flag\\\": {\\n+ \\\"version\\\": \\\"3.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=4\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/nodemon/node_modules/supports-color\\\": {\\n+ \\\"version\\\": \\\"5.5.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"has-flag\\\": \\\"^3.0.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=4\\\"\\n+ }\\n+ },\\n \\\"node_modules/normalize-path\\\": {\\n \\\"version\\\": \\\"3.0.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz\\\",\\n@@ -21889,6 +21663,13 @@\\n \\\"url\\\": \\\"https://github.com/sponsors/lupomontero\\\"\\n }\\n },\\n+ \\\"node_modules/pstree.remy\\\": {\\n+ \\\"version\\\": \\\"1.1.8\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n \\\"node_modules/pump\\\": {\\n \\\"version\\\": \\\"3.0.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/pump/-/pump-3.0.0.tgz\\\",\\n@@ -22269,12 +22050,13 @@\\n }\\n },\\n \\\"node_modules/react-intersection-observer\\\": {\\n- \\\"version\\\": \\\"9.13.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==\\\",\\n+ \\\"version\\\": \\\"9.15.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.15.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-vGrqYEVWXfH+AGu241uzfUpNK4HAdhCkSAyFdkMb9VWWXs6mxzBLpWCxEy9YcnDNY2g9eO6z7qUtTBdA9hc8pA==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n \\\"peerDependencies\\\": {\\n- \\\"react\\\": \\\"^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0\\\",\\n- \\\"react-dom\\\": \\\"^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0\\\"\\n+ \\\"react\\\": \\\"^17.0.0 || ^18.0.0 || ^19.0.0\\\",\\n+ \\\"react-dom\\\": \\\"^17.0.0 || ^18.0.0 || ^19.0.0\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"react-dom\\\": {\\n@@ -22444,19 +22226,19 @@\\n }\\n },\\n \\\"node_modules/react-remove-scroll-bar\\\": {\\n- \\\"version\\\": \\\"2.3.6\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==\\\",\\n+ \\\"version\\\": \\\"2.3.8\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==\\\",\\n \\\"dependencies\\\": {\\n- \\\"react-style-singleton\\\": \\\"^2.2.1\\\",\\n+ \\\"react-style-singleton\\\": \\\"^2.2.2\\\",\\n \\\"tslib\\\": \\\"^2.0.0\\\"\\n },\\n \\\"engines\\\": {\\n \\\"node\\\": \\\">=10\\\"\\n },\\n \\\"peerDependencies\\\": {\\n- \\\"@types/react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\",\\n- \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\"\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -22824,6 +22606,15 @@\\n \\\"@sinonjs/commons\\\": \\\"^1.7.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/@tootallnate/once\\\": {\\n+ \\\"version\\\": \\\"1.1.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">= 6\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/@types/yargs\\\": {\\n \\\"version\\\": \\\"16.0.9\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/@types/yargs/-/yargs-16.0.9.tgz\\\",\\n@@ -22833,6 +22624,37 @@\\n \\\"@types/yargs-parser\\\": \\\"*\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/acorn-globals\\\": {\\n+ \\\"version\\\": \\\"6.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"acorn\\\": \\\"^7.1.1\\\",\\n+ \\\"acorn-walk\\\": \\\"^7.1.1\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/acorn-globals/node_modules/acorn\\\": {\\n+ \\\"version\\\": \\\"7.4.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"bin\\\": {\\n+ \\\"acorn\\\": \\\"bin/acorn\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=0.4.0\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/acorn-walk\\\": {\\n+ \\\"version\\\": \\\"7.2.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=0.4.0\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/babel-jest\\\": {\\n \\\"version\\\": \\\"27.5.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz\\\",\\n@@ -22924,6 +22746,26 @@\\n \\\"wrap-ansi\\\": \\\"^7.0.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/cssom\\\": {\\n+ \\\"version\\\": \\\"0.4.4\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/data-urls\\\": {\\n+ \\\"version\\\": \\\"2.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"abab\\\": \\\"^2.0.3\\\",\\n+ \\\"whatwg-mimetype\\\": \\\"^2.3.0\\\",\\n+ \\\"whatwg-url\\\": \\\"^8.0.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/dedent\\\": {\\n \\\"version\\\": \\\"0.7.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz\\\",\\n@@ -22939,6 +22781,28 @@\\n \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/domexception\\\": {\\n+ \\\"version\\\": \\\"2.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==\\\",\\n+ \\\"deprecated\\\": \\\"Use your platform's native DOMException instead\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"webidl-conversions\\\": \\\"^5.0.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=8\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/domexception/node_modules/webidl-conversions\\\": {\\n+ \\\"version\\\": \\\"5.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==\\\",\\n+ \\\"license\\\": \\\"BSD-2-Clause\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=8\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/emittery\\\": {\\n \\\"version\\\": \\\"0.8.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz\\\",\\n@@ -22966,6 +22830,58 @@\\n \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/form-data\\\": {\\n+ \\\"version\\\": \\\"3.0.2\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/form-data/-/form-data-3.0.2.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"asynckit\\\": \\\"^0.4.0\\\",\\n+ \\\"combined-stream\\\": \\\"^1.0.8\\\",\\n+ \\\"mime-types\\\": \\\"^2.1.12\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">= 6\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/html-encoding-sniffer\\\": {\\n+ \\\"version\\\": \\\"2.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"whatwg-encoding\\\": \\\"^1.0.5\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/http-proxy-agent\\\": {\\n+ \\\"version\\\": \\\"4.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@tootallnate/once\\\": \\\"1\\\",\\n+ \\\"agent-base\\\": \\\"6\\\",\\n+ \\\"debug\\\": \\\"4\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">= 6\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/iconv-lite\\\": {\\n+ \\\"version\\\": \\\"0.4.24\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"safer-buffer\\\": \\\">= 2.1.2 < 3\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=0.10.0\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/jest\\\": {\\n \\\"version\\\": \\\"27.5.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/jest/-/jest-27.5.1.tgz\\\",\\n@@ -23155,6 +23071,24 @@\\n \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/jest-environment-jsdom\\\": {\\n+ \\\"version\\\": \\\"27.5.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"@jest/environment\\\": \\\"^27.5.1\\\",\\n+ \\\"@jest/fake-timers\\\": \\\"^27.5.1\\\",\\n+ \\\"@jest/types\\\": \\\"^27.5.1\\\",\\n+ \\\"@types/node\\\": \\\"*\\\",\\n+ \\\"jest-mock\\\": \\\"^27.5.1\\\",\\n+ \\\"jest-util\\\": \\\"^27.5.1\\\",\\n+ \\\"jsdom\\\": \\\"^16.6.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/jest-environment-node\\\": {\\n \\\"version\\\": \\\"27.5.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz\\\",\\n@@ -23386,9 +23320,61 @@\\n \\\"string-length\\\": \\\"^4.0.1\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n+ \\\"node\\\": \\\"^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/jsdom\\\": {\\n+ \\\"version\\\": \\\"16.7.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"abab\\\": \\\"^2.0.5\\\",\\n+ \\\"acorn\\\": \\\"^8.2.4\\\",\\n+ \\\"acorn-globals\\\": \\\"^6.0.0\\\",\\n+ \\\"cssom\\\": \\\"^0.4.4\\\",\\n+ \\\"cssstyle\\\": \\\"^2.3.0\\\",\\n+ \\\"data-urls\\\": \\\"^2.0.0\\\",\\n+ \\\"decimal.js\\\": \\\"^10.2.1\\\",\\n+ \\\"domexception\\\": \\\"^2.0.1\\\",\\n+ \\\"escodegen\\\": \\\"^2.0.0\\\",\\n+ \\\"form-data\\\": \\\"^3.0.0\\\",\\n+ \\\"html-encoding-sniffer\\\": \\\"^2.0.1\\\",\\n+ \\\"http-proxy-agent\\\": \\\"^4.0.1\\\",\\n+ \\\"https-proxy-agent\\\": \\\"^5.0.0\\\",\\n+ \\\"is-potential-custom-element-name\\\": \\\"^1.0.1\\\",\\n+ \\\"nwsapi\\\": \\\"^2.2.0\\\",\\n+ \\\"parse5\\\": \\\"6.0.1\\\",\\n+ \\\"saxes\\\": \\\"^5.0.1\\\",\\n+ \\\"symbol-tree\\\": \\\"^3.2.4\\\",\\n+ \\\"tough-cookie\\\": \\\"^4.0.0\\\",\\n+ \\\"w3c-hr-time\\\": \\\"^1.0.2\\\",\\n+ \\\"w3c-xmlserializer\\\": \\\"^2.0.0\\\",\\n+ \\\"webidl-conversions\\\": \\\"^6.1.0\\\",\\n+ \\\"whatwg-encoding\\\": \\\"^1.0.5\\\",\\n+ \\\"whatwg-mimetype\\\": \\\"^2.3.0\\\",\\n+ \\\"whatwg-url\\\": \\\"^8.5.0\\\",\\n+ \\\"ws\\\": \\\"^7.4.6\\\",\\n+ \\\"xml-name-validator\\\": \\\"^3.0.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"canvas\\\": \\\"^2.5.0\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"canvas\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/parse5\\\": {\\n+ \\\"version\\\": \\\"6.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n \\\"node_modules/react-scripts/node_modules/sass-loader\\\": {\\n \\\"version\\\": \\\"12.6.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz\\\",\\n@@ -23426,6 +23412,18 @@\\n }\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/saxes\\\": {\\n+ \\\"version\\\": \\\"5.0.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==\\\",\\n+ \\\"license\\\": \\\"ISC\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"xmlchars\\\": \\\"^2.2.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/source-map\\\": {\\n \\\"version\\\": \\\"0.6.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz\\\",\\n@@ -23435,6 +23433,18 @@\\n \\\"node\\\": \\\">=0.10.0\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/tr46\\\": {\\n+ \\\"version\\\": \\\"2.1.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"punycode\\\": \\\"^2.1.1\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=8\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/v8-to-istanbul\\\": {\\n \\\"version\\\": \\\"8.1.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz\\\",\\n@@ -23458,6 +23468,56 @@\\n \\\"node\\\": \\\">= 8\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/w3c-xmlserializer\\\": {\\n+ \\\"version\\\": \\\"2.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"xml-name-validator\\\": \\\"^3.0.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/webidl-conversions\\\": {\\n+ \\\"version\\\": \\\"6.1.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==\\\",\\n+ \\\"license\\\": \\\"BSD-2-Clause\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10.4\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/whatwg-encoding\\\": {\\n+ \\\"version\\\": \\\"1.0.5\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"iconv-lite\\\": \\\"0.4.24\\\"\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/whatwg-mimetype\\\": {\\n+ \\\"version\\\": \\\"2.3.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==\\\",\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/whatwg-url\\\": {\\n+ \\\"version\\\": \\\"8.7.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"lodash\\\": \\\"^4.7.0\\\",\\n+ \\\"tr46\\\": \\\"^2.1.0\\\",\\n+ \\\"webidl-conversions\\\": \\\"^6.1.0\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n+ },\\n \\\"node_modules/react-scripts/node_modules/write-file-atomic\\\": {\\n \\\"version\\\": \\\"3.0.3\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz\\\",\\n@@ -23470,6 +23530,33 @@\\n \\\"typedarray-to-buffer\\\": \\\"^3.1.5\\\"\\n }\\n },\\n+ \\\"node_modules/react-scripts/node_modules/ws\\\": {\\n+ \\\"version\\\": \\\"7.5.10\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/ws/-/ws-7.5.10.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==\\\",\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=8.3.0\\\"\\n+ },\\n+ \\\"peerDependencies\\\": {\\n+ \\\"bufferutil\\\": \\\"^4.0.1\\\",\\n+ \\\"utf-8-validate\\\": \\\"^5.0.2\\\"\\n+ },\\n+ \\\"peerDependenciesMeta\\\": {\\n+ \\\"bufferutil\\\": {\\n+ \\\"optional\\\": true\\n+ },\\n+ \\\"utf-8-validate\\\": {\\n+ \\\"optional\\\": true\\n+ }\\n+ }\\n+ },\\n+ \\\"node_modules/react-scripts/node_modules/xml-name-validator\\\": {\\n+ \\\"version\\\": \\\"3.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==\\\",\\n+ \\\"license\\\": \\\"Apache-2.0\\\"\\n+ },\\n \\\"node_modules/react-scripts/node_modules/yargs\\\": {\\n \\\"version\\\": \\\"16.2.0\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz\\\",\\n@@ -23497,49 +23584,6 @@\\n \\\"node\\\": \\\">=10\\\"\\n }\\n },\\n- \\\"node_modules/react-scroll-to-bottom\\\": {\\n- \\\"version\\\": \\\"4.2.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/react-scroll-to-bottom/-/react-scroll-to-bottom-4.2.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-1WweuumQc5JLzeAR81ykRdK/cEv9NlCPEm4vSwOGN1qS2qlpGVTyMgdI8Y7ZmaqRmzYBGV5/xPuJQtekYzQFGg==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"@babel/runtime-corejs3\\\": \\\"^7.15.4\\\",\\n- \\\"@emotion/css\\\": \\\"11.1.3\\\",\\n- \\\"classnames\\\": \\\"2.3.1\\\",\\n- \\\"core-js\\\": \\\"3.18.3\\\",\\n- \\\"math-random\\\": \\\"2.0.1\\\",\\n- \\\"prop-types\\\": \\\"15.7.2\\\",\\n- \\\"simple-update-in\\\": \\\"2.2.0\\\"\\n- },\\n- \\\"peerDependencies\\\": {\\n- \\\"react\\\": \\\">= 16.8.6\\\"\\n- }\\n- },\\n- \\\"node_modules/react-scroll-to-bottom/node_modules/classnames\\\": {\\n- \\\"version\\\": \\\"2.3.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==\\\"\\n- },\\n- \\\"node_modules/react-scroll-to-bottom/node_modules/core-js\\\": {\\n- \\\"version\\\": \\\"3.18.3\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/core-js/-/core-js-3.18.3.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-tReEhtMReZaPFVw7dajMx0vlsz3oOb8ajgPoHVYGxr8ErnZ6PcYEvvmjGmXlfpnxpkYSdOQttjB+MvVbCGfvLw==\\\",\\n- \\\"deprecated\\\": \\\"core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.\\\",\\n- \\\"hasInstallScript\\\": true,\\n- \\\"funding\\\": {\\n- \\\"type\\\": \\\"opencollective\\\",\\n- \\\"url\\\": \\\"https://opencollective.com/core-js\\\"\\n- }\\n- },\\n- \\\"node_modules/react-scroll-to-bottom/node_modules/prop-types\\\": {\\n- \\\"version\\\": \\\"15.7.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==\\\",\\n- \\\"dependencies\\\": {\\n- \\\"loose-envify\\\": \\\"^1.4.0\\\",\\n- \\\"object-assign\\\": \\\"^4.1.1\\\",\\n- \\\"react-is\\\": \\\"^16.8.1\\\"\\n- }\\n- },\\n \\\"node_modules/react-select\\\": {\\n \\\"version\\\": \\\"5.7.4\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/react-select/-/react-select-5.7.4.tgz\\\",\\n@@ -23561,20 +23605,19 @@\\n }\\n },\\n \\\"node_modules/react-style-singleton\\\": {\\n- \\\"version\\\": \\\"2.2.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==\\\",\\n+ \\\"version\\\": \\\"2.2.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==\\\",\\n \\\"dependencies\\\": {\\n \\\"get-nonce\\\": \\\"^1.0.0\\\",\\n- \\\"invariant\\\": \\\"^2.2.4\\\",\\n \\\"tslib\\\": \\\"^2.0.0\\\"\\n },\\n \\\"engines\\\": {\\n \\\"node\\\": \\\">=10\\\"\\n },\\n \\\"peerDependencies\\\": {\\n- \\\"@types/react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\",\\n- \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\"\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -24475,15 +24518,16 @@\\n \\\"integrity\\\": \\\"sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==\\\"\\n },\\n \\\"node_modules/saxes\\\": {\\n- \\\"version\\\": \\\"5.0.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==\\\",\\n+ \\\"version\\\": \\\"6.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"ISC\\\",\\n \\\"dependencies\\\": {\\n \\\"xmlchars\\\": \\\"^2.2.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=v12.22.7\\\"\\n }\\n },\\n \\\"node_modules/scheduler\\\": {\\n@@ -24798,10 +24842,18 @@\\n \\\"simple-concat\\\": \\\"^1.0.0\\\"\\n }\\n },\\n- \\\"node_modules/simple-update-in\\\": {\\n- \\\"version\\\": \\\"2.2.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/simple-update-in/-/simple-update-in-2.2.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-FrW41lLiOs82jKxwq39UrE1HDAHOvirKWk4Nv8tqnFFFknVbTxcHZzDS4vt02qqdU/5+KNsQHWzhKHznDBmrww==\\\"\\n+ \\\"node_modules/simple-update-notifier\\\": {\\n+ \\\"version\\\": \\\"2.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"dependencies\\\": {\\n+ \\\"semver\\\": \\\"^7.5.3\\\"\\n+ },\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=10\\\"\\n+ }\\n },\\n \\\"node_modules/sisteransi\\\": {\\n \\\"version\\\": \\\"1.0.5\\\",\\n@@ -25998,6 +26050,16 @@\\n \\\"node\\\": \\\">=0.6\\\"\\n }\\n },\\n+ \\\"node_modules/touch\\\": {\\n+ \\\"version\\\": \\\"3.1.1\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/touch/-/touch-3.1.1.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"ISC\\\",\\n+ \\\"bin\\\": {\\n+ \\\"nodetouch\\\": \\\"bin/nodetouch.js\\\"\\n+ }\\n+ },\\n \\\"node_modules/tough-cookie\\\": {\\n \\\"version\\\": \\\"4.1.4\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz\\\",\\n@@ -26023,15 +26085,16 @@\\n }\\n },\\n \\\"node_modules/tr46\\\": {\\n- \\\"version\\\": \\\"2.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==\\\",\\n+ \\\"version\\\": \\\"3.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n \\\"punycode\\\": \\\"^2.1.1\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=8\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/trim-lines\\\": {\\n@@ -26317,6 +26380,13 @@\\n \\\"url\\\": \\\"https://github.com/sponsors/ljharb\\\"\\n }\\n },\\n+ \\\"node_modules/undefsafe\\\": {\\n+ \\\"version\\\": \\\"2.0.5\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\"\\n+ },\\n \\\"node_modules/underscore\\\": {\\n \\\"version\\\": \\\"1.12.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz\\\",\\n@@ -26570,9 +26640,9 @@\\n }\\n },\\n \\\"node_modules/use-callback-ref\\\": {\\n- \\\"version\\\": \\\"1.3.1\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==\\\",\\n+ \\\"version\\\": \\\"1.3.3\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==\\\",\\n \\\"dependencies\\\": {\\n \\\"tslib\\\": \\\"^2.0.0\\\"\\n },\\n@@ -26580,8 +26650,8 @@\\n \\\"node\\\": \\\">=10\\\"\\n },\\n \\\"peerDependencies\\\": {\\n- \\\"@types/react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\",\\n- \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0\\\"\\n+ \\\"@types/react\\\": \\\"*\\\",\\n+ \\\"react\\\": \\\"^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"@types/react\\\": {\\n@@ -26800,15 +26870,16 @@\\n }\\n },\\n \\\"node_modules/w3c-xmlserializer\\\": {\\n- \\\"version\\\": \\\"2.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==\\\",\\n+ \\\"version\\\": \\\"4.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"xml-name-validator\\\": \\\"^3.0.0\\\"\\n+ \\\"xml-name-validator\\\": \\\"^4.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=14\\\"\\n }\\n },\\n \\\"node_modules/walker\\\": {\\n@@ -26854,12 +26925,12 @@\\n \\\"integrity\\\": \\\"sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==\\\"\\n },\\n \\\"node_modules/webidl-conversions\\\": {\\n- \\\"version\\\": \\\"6.1.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==\\\",\\n+ \\\"version\\\": \\\"7.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==\\\",\\n \\\"license\\\": \\\"BSD-2-Clause\\\",\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10.4\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/webpack\\\": {\\n@@ -27086,26 +27157,6 @@\\n \\\"url\\\": \\\"https://opencollective.com/webpack\\\"\\n }\\n },\\n- \\\"node_modules/webpack-dev-server/node_modules/ws\\\": {\\n- \\\"version\\\": \\\"8.14.2\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/ws/-/ws-8.14.2.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==\\\",\\n- \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10.0.0\\\"\\n- },\\n- \\\"peerDependencies\\\": {\\n- \\\"bufferutil\\\": \\\"^4.0.1\\\",\\n- \\\"utf-8-validate\\\": \\\">=5.0.2\\\"\\n- },\\n- \\\"peerDependenciesMeta\\\": {\\n- \\\"bufferutil\\\": {\\n- \\\"optional\\\": true\\n- },\\n- \\\"utf-8-validate\\\": {\\n- \\\"optional\\\": true\\n- }\\n- }\\n- },\\n \\\"node_modules/webpack-manifest-plugin\\\": {\\n \\\"version\\\": \\\"4.1.1\\\",\\n \\\"resolved\\\": \\\"https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz\\\",\\n@@ -27191,24 +27242,16 @@\\n }\\n },\\n \\\"node_modules/whatwg-encoding\\\": {\\n- \\\"version\\\": \\\"1.0.5\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==\\\",\\n- \\\"license\\\": \\\"MIT\\\",\\n- \\\"dependencies\\\": {\\n- \\\"iconv-lite\\\": \\\"0.4.24\\\"\\n- }\\n- },\\n- \\\"node_modules/whatwg-encoding/node_modules/iconv-lite\\\": {\\n- \\\"version\\\": \\\"0.4.24\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==\\\",\\n+ \\\"version\\\": \\\"2.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"safer-buffer\\\": \\\">= 2.1.2 < 3\\\"\\n+ \\\"iconv-lite\\\": \\\"0.6.3\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=0.10.0\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/whatwg-fetch\\\": {\\n@@ -27217,23 +27260,27 @@\\n \\\"integrity\\\": \\\"sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==\\\"\\n },\\n \\\"node_modules/whatwg-mimetype\\\": {\\n- \\\"version\\\": \\\"2.3.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==\\\",\\n- \\\"license\\\": \\\"MIT\\\"\\n+ \\\"version\\\": \\\"3.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"MIT\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=12\\\"\\n+ }\\n },\\n \\\"node_modules/whatwg-url\\\": {\\n- \\\"version\\\": \\\"8.7.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==\\\",\\n+ \\\"version\\\": \\\"11.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==\\\",\\n+ \\\"dev\\\": true,\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"dependencies\\\": {\\n- \\\"lodash\\\": \\\"^4.7.0\\\",\\n- \\\"tr46\\\": \\\"^2.1.0\\\",\\n- \\\"webidl-conversions\\\": \\\"^6.1.0\\\"\\n+ \\\"tr46\\\": \\\"^3.0.0\\\",\\n+ \\\"webidl-conversions\\\": \\\"^7.0.0\\\"\\n },\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=10\\\"\\n+ \\\"node\\\": \\\">=12\\\"\\n }\\n },\\n \\\"node_modules/which\\\": {\\n@@ -27646,16 +27693,16 @@\\n }\\n },\\n \\\"node_modules/ws\\\": {\\n- \\\"version\\\": \\\"7.5.10\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/ws/-/ws-7.5.10.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==\\\",\\n+ \\\"version\\\": \\\"8.18.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/ws/-/ws-8.18.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==\\\",\\n \\\"license\\\": \\\"MIT\\\",\\n \\\"engines\\\": {\\n- \\\"node\\\": \\\">=8.3.0\\\"\\n+ \\\"node\\\": \\\">=10.0.0\\\"\\n },\\n \\\"peerDependencies\\\": {\\n \\\"bufferutil\\\": \\\"^4.0.1\\\",\\n- \\\"utf-8-validate\\\": \\\"^5.0.2\\\"\\n+ \\\"utf-8-validate\\\": \\\">=5.0.2\\\"\\n },\\n \\\"peerDependenciesMeta\\\": {\\n \\\"bufferutil\\\": {\\n@@ -27667,10 +27714,14 @@\\n }\\n },\\n \\\"node_modules/xml-name-validator\\\": {\\n- \\\"version\\\": \\\"3.0.0\\\",\\n- \\\"resolved\\\": \\\"https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz\\\",\\n- \\\"integrity\\\": \\\"sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==\\\",\\n- \\\"license\\\": \\\"Apache-2.0\\\"\\n+ \\\"version\\\": \\\"4.0.0\\\",\\n+ \\\"resolved\\\": \\\"https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz\\\",\\n+ \\\"integrity\\\": \\\"sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==\\\",\\n+ \\\"dev\\\": true,\\n+ \\\"license\\\": \\\"Apache-2.0\\\",\\n+ \\\"engines\\\": {\\n+ \\\"node\\\": \\\">=12\\\"\\n+ }\\n },\\n \\\"node_modules/xmlchars\\\": {\\n \\\"version\\\": \\\"2.2.0\\\",\\ndiff --git a/package.json b/package.json\\nindex ddd8f68..02a70c4 100644\\n--- a/package.json\\n+++ b/package.json\\n@@ -1,8 +1,9 @@\\n {\\n \\\"name\\\": \\\"labeeb\\\",\\n- \\\"version\\\": \\\"2.4.22\\\",\\n+ \\\"version\\\": \\\"2.5.0\\\",\\n \\\"private\\\": true,\\n \\\"dependencies\\\": {\\n+ \\\"@aj-archipelago/subvibe\\\": \\\"^1.0.8\\\",\\n \\\"@amplitude/analytics-browser\\\": \\\"^2.3.2\\\",\\n \\\"@apollo/client\\\": \\\"^3.10.4\\\",\\n \\\"@apollo/experimental-nextjs-app-support\\\": \\\"^0.11.0\\\",\\n@@ -10,6 +11,7 @@\\n \\\"@hello-pangea/dnd\\\": \\\"^16.6.0\\\",\\n \\\"@heroicons/react\\\": \\\"^2.0.18\\\",\\n \\\"@radix-ui/react-accordion\\\": \\\"^1.1.2\\\",\\n+ \\\"@radix-ui/react-alert-dialog\\\": \\\"^1.1.4\\\",\\n \\\"@radix-ui/react-checkbox\\\": \\\"^1.1.2\\\",\\n \\\"@radix-ui/react-dialog\\\": \\\"^1.1.2\\\",\\n \\\"@radix-ui/react-dismissable-layer\\\": \\\"^1.1.1\\\",\\n@@ -17,7 +19,7 @@\\n \\\"@radix-ui/react-popover\\\": \\\"^1.0.7\\\",\\n \\\"@radix-ui/react-progress\\\": \\\"^1.0.3\\\",\\n \\\"@radix-ui/react-select\\\": \\\"^2.1.2\\\",\\n- \\\"@radix-ui/react-slot\\\": \\\"^1.0.2\\\",\\n+ \\\"@radix-ui/react-slot\\\": \\\"^1.1.1\\\",\\n \\\"@radix-ui/react-tabs\\\": \\\"^1.0.4\\\",\\n \\\"@radix-ui/react-toast\\\": \\\"^1.2.2\\\",\\n \\\"@radix-ui/react-toggle\\\": \\\"^1.0.3\\\",\\n@@ -64,7 +66,7 @@\\n \\\"react-filepond\\\": \\\"^7.1.2\\\",\\n \\\"react-i18next\\\": \\\"^12.2.0\\\",\\n \\\"react-icons\\\": \\\"^4.7.1\\\",\\n- \\\"react-intersection-observer\\\": \\\"^9.13.1\\\",\\n+ \\\"react-intersection-observer\\\": \\\"^9.15.1\\\",\\n \\\"react-markdown\\\": \\\"^9.0.1\\\",\\n \\\"react-monaco-editor\\\": \\\"^0.55.0\\\",\\n \\\"react-player\\\": \\\"^2.16.0\\\",\\n@@ -73,7 +75,6 @@\\n \\\"react-redux\\\": \\\"^8.0.5\\\",\\n \\\"react-router-dom\\\": \\\"^6.8.1\\\",\\n \\\"react-scripts\\\": \\\"5.0.1\\\",\\n- \\\"react-scroll-to-bottom\\\": \\\"^4.2.0\\\",\\n \\\"react-select\\\": \\\"^5.7.3\\\",\\n \\\"react-textarea-autosize\\\": \\\"^8.4.0\\\",\\n \\\"react-time-ago\\\": \\\"^7.3.1\\\",\\n@@ -101,6 +102,7 @@\\n \\\"lint\\\": \\\"next lint && npx prettier --check .\\\",\\n \\\"format\\\": \\\"npx prettier --write .\\\",\\n \\\"worker\\\": \\\"node ./jobs/worker.js\\\",\\n+ \\\"worker:dev\\\": \\\"nodemon --watch ./jobs/worker.js --exec 'node ./jobs/worker.js'\\\",\\n \\\"test\\\": \\\"jest\\\"\\n },\\n \\\"eslintConfig\\\": {\\n@@ -122,6 +124,7 @@\\n ]\\n },\\n \\\"devDependencies\\\": {\\n+ \\\"@babel/plugin-proposal-private-property-in-object\\\": \\\"^7.21.11\\\",\\n \\\"@babel/preset-env\\\": \\\"^7.26.0\\\",\\n \\\"@babel/preset-react\\\": \\\"^7.26.3\\\",\\n \\\"@tailwindcss/forms\\\": \\\"^0.5.7\\\",\\n@@ -129,7 +132,9 @@\\n \\\"babel-jest\\\": \\\"^29.7.0\\\",\\n \\\"customize-cra\\\": \\\"^1.0.0\\\",\\n \\\"jest\\\": \\\"^29.7.0\\\",\\n+ \\\"jest-environment-jsdom\\\": \\\"^29.7.0\\\",\\n \\\"mongodb-memory-server\\\": \\\"^10.1.3\\\",\\n+ \\\"nodemon\\\": \\\"^3.1.9\\\",\\n \\\"postcss\\\": \\\"^8.4.31\\\",\\n \\\"prettier\\\": \\\"^3.2.2\\\",\\n \\\"react-app-rewired\\\": \\\"^2.2.1\\\",\\ndiff --git a/src/App.js b/src/App.js\\nindex 69ec584..82cd750 100644\\n--- a/src/App.js\\n+++ b/src/App.js\\n@@ -18,6 +18,7 @@ import \\\"./App.scss\\\";\\n import StoreProvider from \\\"./StoreProvider\\\";\\n import { LanguageContext, LanguageProvider } from \\\"./contexts/LanguageProvider\\\";\\n import { ThemeProvider } from \\\"./contexts/ThemeProvider\\\";\\n+import { AutoTranscribeProvider } from \\\"./contexts/AutoTranscribeContext\\\";\\n import Layout from \\\"./layout/Layout\\\";\\n import \\\"./tailwind.css\\\";\\n \\n@@ -42,17 +43,29 @@ const App = ({\\n neuralspaceEnabled,\\n }) => {\\n const { data: currentUser } = useCurrentUser();\\n- const { data: serverUserState } = useUserState();\\n+ const { data: serverUserState, refetch: refetchServerUserState } =\\n+ useUserState();\\n const updateUserState = useUpdateUserState();\\n- const [userState, setUserState] = useState(serverUserState);\\n+ const [userState, setUserState] = useState(null);\\n const debouncedUserState = useDebounce(userState, STATE_DEBOUNCE_TIME);\\n+ const [refetchCalled, setRefetchCalled] = useState(false);\\n+\\n+ const refetchUserState = () => {\\n+ setRefetchCalled(true);\\n+ refetchServerUserState();\\n+ };\\n \\n useEffect(() => {\\n- // set user state from server if it exists\\n- if (!userState && serverUserState) {\\n+ // set user state from server if it exists, but only if there's no client\\n+ // state yet\\n+ if (\\n+ (!userState || refetchCalled) &&\\n+ JSON.stringify(serverUserState) !== JSON.stringify(userState)\\n+ ) {\\n setUserState(serverUserState);\\n+ setRefetchCalled(false);\\n }\\n- }, [userState, serverUserState]);\\n+ }, [userState, serverUserState, refetchCalled]);\\n \\n useEffect(() => {\\n if (i18next.language !== language) {\\n@@ -94,19 +107,22 @@ const App = ({\\n <StoreProvider>\\n <ThemeProvider savedTheme={theme}>\\n <LanguageProvider savedLanguage={language}>\\n- <React.StrictMode>\\n- <AuthContext.Provider\\n- value={{\\n- user: currentUser,\\n- userState,\\n- debouncedUpdateUserState,\\n- }}\\n- >\\n- <Layout>\\n- <Body>{children}</Body>\\n- </Layout>\\n- </AuthContext.Provider>\\n- </React.StrictMode>\\n+ <AutoTranscribeProvider>\\n+ <React.StrictMode>\\n+ <AuthContext.Provider\\n+ value={{\\n+ user: currentUser,\\n+ userState,\\n+ refetchUserState,\\n+ debouncedUpdateUserState,\\n+ }}\\n+ >\\n+ <Layout>\\n+ <Body>{children}</Body>\\n+ </Layout>\\n+ </AuthContext.Provider>\\n+ </React.StrictMode>\\n+ </AutoTranscribeProvider>\\n </LanguageProvider>\\n </ThemeProvider>\\n </StoreProvider>\\ndiff --git a/src/__tests__/App.test.js b/src/__tests__/App.test.js\\nnew file mode 100644\\nindex 0000000..b8cff1e\\n--- /dev/null\\n+++ b/src/__tests__/App.test.js\\n@@ -0,0 +1,434 @@\\n+import { act, render, waitFor } from \\\"@testing-library/react\\\";\\n+import React from \\\"react\\\";\\n+import {\\n+ useCurrentUser,\\n+ useUpdateUserState,\\n+ useUserState,\\n+} from \\\"../../app/queries/users\\\";\\n+import { AuthContext } from \\\"../App\\\";\\n+import {\\n+ LanguageContext,\\n+ LanguageProvider,\\n+} from \\\"../contexts/LanguageProvider\\\";\\n+\\n+// Create a mock language context before mocking\\n+const mockLanguageContext = {\\n+ language: \\\"en\\\",\\n+ direction: \\\"ltr\\\",\\n+ changeLanguage: jest.fn(),\\n+};\\n+\\n+// Mock style imports - removing virtual: true option\\n+jest.mock(\\\"../App.scss\\\", () => ({}));\\n+jest.mock(\\\"../tailwind.css\\\", () => ({}));\\n+\\n+// Mock React's useContext to return our mockLanguageContext when LanguageContext is requested\\n+const originalUseContext = React.useContext;\\n+React.useContext = jest.fn((context) => {\\n+ // Check if this is the LanguageContext\\n+ if (context === LanguageContext) {\\n+ return mockLanguageContext;\\n+ }\\n+ // Otherwise use the original implementation\\n+ return originalUseContext(context);\\n+});\\n+\\n+// Mock the modules and hooks before importing App\\n+jest.mock(\\\"@apollo/experimental-nextjs-app-support\\\", () => ({\\n+ ApolloNextAppProvider: ({ children }) => children,\\n+}));\\n+\\n+jest.mock(\\\"../graphql\\\", () => ({\\n+ getClient: jest.fn(() => ({})),\\n+}));\\n+\\n+jest.mock(\\\"../i18n\\\", () => ({}));\\n+\\n+jest.mock(\\\"@amplitude/analytics-browser\\\", () => ({\\n+ init: jest.fn(),\\n+}));\\n+\\n+// Mock useDebounce hook\\n+jest.mock(\\\"@uidotdev/usehooks\\\", () => ({\\n+ useDebounce: jest.fn((val) => val), // By default, return the value immediately without debouncing\\n+}));\\n+\\n+jest.mock(\\\"../../app/queries/users\\\", () => ({\\n+ useCurrentUser: jest.fn(),\\n+ useUserState: jest.fn(),\\n+ useUpdateUserState: jest.fn(),\\n+}));\\n+\\n+jest.mock(\\\"../StoreProvider\\\", () => ({\\n+ __esModule: true,\\n+ default: ({ children }) => (\\n+ <div data-testid=\\\"store-provider\\\">{children}</div>\\n+ ),\\n+}));\\n+\\n+jest.mock(\\\"../contexts/LanguageProvider\\\", () => {\\n+ return {\\n+ LanguageProvider: ({ children }) => {\\n+ const mockContext = {\\n+ language: \\\"en\\\",\\n+ direction: \\\"ltr\\\",\\n+ changeLanguage: jest.fn(),\\n+ };\\n+\\n+ // Import the actual context\\n+ const { LanguageContext } = jest.requireActual(\\n+ \\\"../contexts/LanguageProvider\\\",\\n+ );\\n+\\n+ // Return the Provider with our mock value\\n+ return (\\n+ <LanguageContext.Provider value={mockContext}>\\n+ {children}\\n+ </LanguageContext.Provider>\\n+ );\\n+ },\\n+ // Export the actual LanguageContext\\n+ LanguageContext: jest.requireActual(\\\"../contexts/LanguageProvider\\\")\\n+ .LanguageContext,\\n+ };\\n+});\\n+\\n+jest.mock(\\\"../contexts/ThemeProvider\\\", () => ({\\n+ ThemeProvider: ({ children }) => (\\n+ <div data-testid=\\\"theme-provider\\\">{children}</div>\\n+ ),\\n+}));\\n+\\n+jest.mock(\\\"../contexts/AutoTranscribeContext\\\", () => ({\\n+ AutoTranscribeProvider: ({ children }) => (\\n+ <div data-testid=\\\"auto-transcribe-provider\\\">{children}</div>\\n+ ),\\n+}));\\n+\\n+jest.mock(\\\"../layout/Layout\\\", () => ({\\n+ __esModule: true,\\n+ default: ({ children }) => <div data-testid=\\\"layout\\\">{children}</div>,\\n+}));\\n+\\n+// Mock classNames utility\\n+jest.mock(\\\"../../app/utils/class-names\\\", () => ({\\n+ __esModule: true,\\n+ default: (...classes) => classes.filter(Boolean).join(\\\" \\\"),\\n+}));\\n+\\n+// Create a mock for dayjs\\n+jest.mock(\\\"dayjs\\\", () => {\\n+ const originalDayjs = jest.requireActual(\\\"dayjs\\\");\\n+ return Object.assign(\\n+ jest.fn(() => originalDayjs()),\\n+ {\\n+ locale: jest.fn(),\\n+ },\\n+ );\\n+});\\n+\\n+// Create a mock for i18next\\n+jest.mock(\\\"i18next\\\", () => ({\\n+ language: \\\"en\\\",\\n+ changeLanguage: jest.fn(),\\n+}));\\n+\\n+// Import App after all mocks are set up\\n+// eslint-disable-next-line import/first\\n+import { useDebounce } from \\\"@uidotdev/usehooks\\\";\\n+// eslint-disable-next-line import/first\\n+import App from \\\"../App\\\";\\n+\\n+// Mock process.env\\n+process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY = \\\"test-api-key\\\";\\n+\\n+describe(\\\"App Component\\\", () => {\\n+ // Setup for all tests\\n+ const mockRefetch = jest.fn();\\n+ const mockMutate = jest.fn();\\n+\\n+ beforeEach(() => {\\n+ jest.clearAllMocks();\\n+\\n+ // Default mock implementations\\n+ useCurrentUser.mockReturnValue({\\n+ data: { id: \\\"user1\\\", name: \\\"Test User\\\" },\\n+ });\\n+ useUserState.mockReturnValue({\\n+ data: { preferences: { theme: \\\"light\\\" } },\\n+ refetch: mockRefetch,\\n+ });\\n+ useUpdateUserState.mockReturnValue({ mutate: mockMutate });\\n+\\n+ // Reset useDebounce to pass through values by default\\n+ useDebounce.mockImplementation((val) => val);\\n+ });\\n+\\n+ describe(\\\"User State Management\\\", () => {\\n+ it(\\\"should initialize userState from server when client state is null\\\", async () => {\\n+ // Setup initial state\\n+ const serverState = {\\n+ preferences: { theme: \\\"dark\\\", fontSize: \\\"medium\\\" },\\n+ };\\n+ useUserState.mockReturnValue({\\n+ data: serverState,\\n+ refetch: mockRefetch,\\n+ });\\n+\\n+ // Render component\\n+ render(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // First render should set userState from server\\n+ await waitFor(() => {\\n+ expect(useUserState().data).toEqual(serverState);\\n+ });\\n+ });\\n+\\n+ it(\\\"should not overwrite client userState with server state on re-render\\\", async () => {\\n+ // Mock useState to capture state updates\\n+ const setUserStateMock = jest.fn();\\n+ let userStateValue = null;\\n+\\n+ // Save the original useState\\n+ const originalUseState = React.useState;\\n+\\n+ // Create a mock implementation that tracks userState specifically\\n+ const mockUseState = jest.fn((initialValue) => {\\n+ // Only intercept the userState (null initial value)\\n+ if (initialValue === null) {\\n+ return [userStateValue, setUserStateMock];\\n+ }\\n+ // For all other useState calls, use the original implementation\\n+ return originalUseState(initialValue);\\n+ });\\n+\\n+ // Apply our mock implementation\\n+ jest.spyOn(React, \\\"useState\\\").mockImplementation(mockUseState);\\n+\\n+ // Initial server state\\n+ const serverState = { preferences: { theme: \\\"light\\\" } };\\n+ useUserState.mockReturnValue({\\n+ data: serverState,\\n+ refetch: mockRefetch,\\n+ });\\n+\\n+ // Initial render\\n+ const { rerender } = render(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // Manually trigger the effect that would set the state\\n+ act(() => {\\n+ // Find the useState call for userState and call its setter\\n+ setUserStateMock(serverState);\\n+ });\\n+\\n+ // Verify setUserState was called with server state\\n+ await waitFor(() => {\\n+ expect(setUserStateMock).toHaveBeenCalledWith(serverState);\\n+ });\\n+\\n+ // Now simulate client state being set\\n+ userStateValue = {\\n+ preferences: { theme: \\\"dark\\\", fontSize: \\\"large\\\" },\\n+ };\\n+ setUserStateMock.mockClear();\\n+\\n+ // Change server state\\n+ useUserState.mockReturnValue({\\n+ data: { preferences: { theme: \\\"system\\\" } }, // Different server state\\n+ refetch: mockRefetch,\\n+ });\\n+\\n+ // Re-render\\n+ rerender(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // Verify setUserState was NOT called again (client state preserved)\\n+ expect(setUserStateMock).not.toHaveBeenCalled();\\n+\\n+ // Restore original useState\\n+ React.useState.mockRestore();\\n+ });\\n+\\n+ it(\\\"should update server when client state changes\\\", async () => {\\n+ // Setup for testing debounce\\n+ let debouncedValue = null;\\n+ useDebounce.mockImplementation((value) => {\\n+ debouncedValue = value;\\n+ return value;\\n+ });\\n+\\n+ // Setup state mock\\n+ const setUserStateMock = jest.fn();\\n+ let userStateValue = null;\\n+\\n+ const originalUseState = React.useState;\\n+ jest.spyOn(React, \\\"useState\\\").mockImplementation((initialValue) => {\\n+ if (initialValue === null) {\\n+ return [userStateValue, setUserStateMock];\\n+ }\\n+ return originalUseState(initialValue);\\n+ });\\n+\\n+ // Render component\\n+ render(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // Simulate state update\\n+ const updatedState = { preferences: { theme: \\\"dark\\\" } };\\n+ userStateValue = updatedState;\\n+\\n+ // Trigger useEffect that watches debouncedUserState\\n+ // eslint-disable-next-line testing-library/no-unnecessary-act\\n+ act(() => {\\n+ // Force re-render by updating a prop\\n+ render(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"dark\\\" // Changed prop to force re-render\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+ });\\n+\\n+ // Check if updateUserState.mutate was called with the updated state\\n+ await waitFor(() => {\\n+ expect(mockMutate).toHaveBeenCalledWith(debouncedValue);\\n+ });\\n+\\n+ // Restore original useState\\n+ React.useState.mockRestore();\\n+ });\\n+\\n+ it(\\\"should call refetch and set refetchCalled when refetchUserState is called\\\", async () => {\\n+ // Mock the refetch function\\n+ const mockRefetch = jest.fn();\\n+\\n+ // Setup server state\\n+ const serverState = { preferences: { theme: \\\"light\\\" } };\\n+ useUserState.mockReturnValue({\\n+ data: serverState,\\n+ refetch: mockRefetch,\\n+ });\\n+\\n+ // Create a container to store the captured context value\\n+ let capturedContextValue = null;\\n+\\n+ // Mock the AuthContext.Provider to capture its value\\n+ const originalProvider = AuthContext.Provider;\\n+ AuthContext.Provider = ({ value, children }) => {\\n+ capturedContextValue = value;\\n+ return React.createElement(originalProvider, {\\n+ value,\\n+ children,\\n+ });\\n+ };\\n+\\n+ // Render the component\\n+ const { rerender } = render(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // Store the initial userState\\n+ const initialUserState = capturedContextValue.userState;\\n+\\n+ // Clear previous calls\\n+ mockRefetch.mockClear();\\n+\\n+ // Call refetchUserState directly - no need for act() here\\n+ capturedContextValue.refetchUserState();\\n+\\n+ // Verify the refetch function was called\\n+ expect(mockRefetch).toHaveBeenCalled();\\n+\\n+ // Update the server state to simulate a successful refetch\\n+ const updatedServerState = { preferences: { theme: \\\"dark\\\" } };\\n+ useUserState.mockReturnValue({\\n+ data: updatedServerState,\\n+ refetch: mockRefetch,\\n+ });\\n+\\n+ // Re-render to trigger the useEffect that depends on serverUserState\\n+ rerender(\\n+ <LanguageProvider>\\n+ <App\\n+ language=\\\"en\\\"\\n+ theme=\\\"light\\\"\\n+ serverUrl=\\\"http://example.com\\\"\\n+ graphQLPublicEndpoint=\\\"http://example.com/graphql\\\"\\n+ >\\n+ Test Content\\n+ </App>\\n+ </LanguageProvider>,\\n+ );\\n+\\n+ // First, wait for the userState to be different from the initial state\\n+ await waitFor(() => {\\n+ expect(capturedContextValue.userState).not.toEqual(\\n+ initialUserState,\\n+ );\\n+ });\\n+\\n+ // Then, verify it matches the updated server state\\n+ expect(capturedContextValue.userState).toEqual(updatedServerState);\\n+\\n+ // Restore the original provider\\n+ AuthContext.Provider = originalProvider;\\n+ });\\n+ });\\n+});\\ndiff --git a/src/components/CopyButton.js b/src/components/CopyButton.js\\nindex b51f2c3..64b3891 100644\\n--- a/src/components/CopyButton.js\\n+++ b/src/components/CopyButton.js\\n@@ -15,17 +15,26 @@ function CopyButton({ item, className = \\\"absolute top-1 end-1 \\\" }) {\\n }, [copied]);\\n \\n const copyFormattedText = async (text) => {\\n+ // If text is undefined or null, use an empty string instead\\n+ const textToCopy = text || \\\"\\\";\\n+\\n try {\\n- const html = marked(text);\\n+ const html = marked(textToCopy);\\n const blob = new Blob([html], { type: \\\"text/html\\\" });\\n const clipboardItem = new ClipboardItem({\\n \\\"text/html\\\": blob,\\n- \\\"text/plain\\\": new Blob([text], { type: \\\"text/plain\\\" }),\\n+ \\\"text/plain\\\": new Blob([textToCopy], { type: \\\"text/plain\\\" }),\\n });\\n await navigator.clipboard.write([clipboardItem]);\\n setCopied(true);\\n } catch (err) {\\n- console.error(\\\"Failed to copy text: \\\", err);\\n+ // Fallback to basic clipboard API if rich text copy fails\\n+ try {\\n+ await navigator.clipboard.writeText(textToCopy);\\n+ setCopied(true);\\n+ } catch (clipboardErr) {\\n+ console.error(\\\"Failed to copy text: \\\", clipboardErr);\\n+ }\\n }\\n };\\n \\ndiff --git a/src/components/UserOptions.js b/src/components/UserOptions.js\\nindex 2784565..dabf7f0 100644\\n--- a/src/components/UserOptions.js\\n+++ b/src/components/UserOptions.js\\n@@ -1,7 +1,8 @@\\n import { Modal } from \\\"@/components/ui/modal\\\";\\n import { useApolloClient, useQuery } from \\\"@apollo/client\\\";\\n-import { useContext, useEffect, useState } from \\\"react\\\";\\n+import { useContext, useEffect, useRef, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n+import { FiDownload, FiUpload } from \\\"react-icons/fi\\\";\\n import { useUpdateAiOptions } from \\\"../../app/queries/options\\\";\\n import { QUERIES } from \\\"../../src/graphql\\\";\\n import { AuthContext } from \\\"../App\\\";\\n@@ -9,18 +10,24 @@ import { AuthContext } from \\\"../App\\\";\\n const UserOptions = ({ show, handleClose }) => {\\n const { t } = useTranslation();\\n const { user } = useContext(AuthContext);\\n+ const fileInputRef = useRef();\\n const [aiMemorySelfModify, setAiMemorySelfModify] = useState(\\n user.aiMemorySelfModify || false,\\n );\\n const [aiName, setAiName] = useState(user.aiName || \\\"Labeeb\\\");\\n const [aiStyle, setAiStyle] = useState(user.aiStyle || \\\"OpenAI\\\");\\n+ const [streamingEnabled, setStreamingEnabled] = useState(\\n+ user.streamingEnabled || false,\\n+ );\\n const [activeMemoryTab, setActiveMemoryTab] = useState(\\\"user\\\");\\n const [parsedMemory, setParsedMemory] = useState({\\n memorySelf: \\\"\\\",\\n memoryDirectives: \\\"\\\",\\n memoryUser: \\\"\\\",\\n memoryTopics: \\\"\\\",\\n+ memoryVersion: \\\"\\\",\\n });\\n+ const [uploadError, setUploadError] = useState(\\\"\\\");\\n \\n const updateAiOptionsMutation = useUpdateAiOptions();\\n const apolloClient = useApolloClient();\\n@@ -52,6 +59,7 @@ const UserOptions = ({ show, handleClose }) => {\\n memoryDirectives: parsed.memoryDirectives || \\\"\\\",\\n memoryUser: parsed.memoryUser || \\\"\\\",\\n memoryTopics: parsed.memoryTopics || \\\"\\\",\\n+ memoryVersion: parsed.memoryVersion || \\\"\\\",\\n });\\n } catch (e) {\\n // If parsing fails, put everything in memoryUser\\n@@ -60,6 +68,7 @@ const UserOptions = ({ show, handleClose }) => {\\n memoryDirectives: \\\"\\\",\\n memoryUser: memoryData.sys_read_memory.result || \\\"\\\",\\n memoryTopics: \\\"\\\",\\n+ memoryVersion: \\\"\\\",\\n });\\n }\\n }\\n@@ -75,6 +84,7 @@ const UserOptions = ({ show, handleClose }) => {\\n memoryDirectives: \\\"\\\",\\n memoryUser: \\\"\\\",\\n memoryTopics: \\\"\\\",\\n+ memoryVersion: \\\"\\\",\\n });\\n };\\n \\n@@ -90,6 +100,7 @@ const UserOptions = ({ show, handleClose }) => {\\n aiMemorySelfModify,\\n aiName,\\n aiStyle,\\n+ streamingEnabled,\\n });\\n \\n const combinedMemory = JSON.stringify(parsedMemory);\\n@@ -112,6 +123,65 @@ const UserOptions = ({ show, handleClose }) => {\\n handleClose();\\n };\\n \\n+ const handleDownloadMemory = () => {\\n+ const blob = new Blob([JSON.stringify(parsedMemory, null, 2)], {\\n+ type: \\\"application/json\\\",\\n+ });\\n+ const url = URL.createObjectURL(blob);\\n+ const a = document.createElement(\\\"a\\\");\\n+ a.href = url;\\n+ const now = new Date();\\n+ const date = now.toISOString().split(\\\"T\\\")[0];\\n+ const time = now.toTimeString().split(\\\" \\\")[0].replace(/:/g, \\\"-\\\");\\n+ a.download = `${aiName.toLowerCase()}-memory-${date}-${time}.json`;\\n+ document.body.appendChild(a);\\n+ a.click();\\n+ document.body.removeChild(a);\\n+ URL.revokeObjectURL(url);\\n+ };\\n+\\n+ const handleUploadMemory = (event) => {\\n+ const file = event.target.files[0];\\n+ setUploadError(\\\"\\\"); // Clear any previous errors\\n+ if (file) {\\n+ const reader = new FileReader();\\n+ reader.onload = (e) => {\\n+ try {\\n+ const uploaded = JSON.parse(e.target.result);\\n+ // Validate the required memory structure\\n+ if (!uploaded || typeof uploaded !== \\\"object\\\") {\\n+ throw new Error(t(\\\"Invalid memory file format\\\"));\\n+ }\\n+ setParsedMemory({\\n+ memorySelf: uploaded.memorySelf || \\\"\\\",\\n+ memoryDirectives: uploaded.memoryDirectives || \\\"\\\",\\n+ memoryUser: uploaded.memoryUser || \\\"\\\",\\n+ memoryTopics: uploaded.memoryTopics || \\\"\\\",\\n+ memoryVersion: uploaded.memoryVersion || \\\"\\\",\\n+ });\\n+ } catch (error) {\\n+ console.error(\\\"Failed to parse memory file:\\\", error);\\n+ setUploadError(\\n+ t(\\n+ \\\"Failed to parse memory file. Please ensure it is a valid JSON file with the correct memory structure.\\\",\\n+ ),\\n+ );\\n+ // Reset the file input so the same file can be selected again\\n+ if (fileInputRef.current) {\\n+ fileInputRef.current.value = \\\"\\\";\\n+ }\\n+ }\\n+ };\\n+ reader.onerror = () => {\\n+ setUploadError(t(\\\"Failed to read the file. Please try again.\\\"));\\n+ if (fileInputRef.current) {\\n+ fileInputRef.current.value = \\\"\\\";\\n+ }\\n+ };\\n+ reader.readAsText(file);\\n+ }\\n+ };\\n+\\n const memoryTabs = [\\n { id: \\\"user\\\", label: \\\"User Memory\\\" },\\n { id: \\\"self\\\", label: \\\"Self Memory\\\" },\\n@@ -171,6 +241,24 @@ const UserOptions = ({ show, handleClose }) => {\\n <option value=\\\"Anthropic\\\">{t(\\\"Anthropic\\\")}</option>\\n </select>\\n \\n+ <h4 className=\\\"text-base font-semibold mb-2\\\">\\n+ {t(\\\"Chat Options\\\")}\\n+ </h4>\\n+ <div className=\\\"flex gap-2 items-center mb-4\\\">\\n+ <input\\n+ type=\\\"checkbox\\\"\\n+ size=\\\"sm\\\"\\n+ id=\\\"streamingEnabled\\\"\\n+ className=\\\"accent-sky-500\\\"\\n+ checked={streamingEnabled}\\n+ onChange={(e) => setStreamingEnabled(e.target.checked)}\\n+ style={{ margin: \\\"0.5rem 0\\\" }}\\n+ />\\n+ <label htmlFor=\\\"streamingEnabled\\\">\\n+ {t(\\\"Enable streaming responses\\\")}\\n+ </label>\\n+ </div>\\n+\\n <h4 className=\\\"text-base font-semibold mb-2\\\">\\n {t(\\\"AI Memory\\\")}\\n </h4>\\n@@ -203,19 +291,51 @@ const UserOptions = ({ show, handleClose }) => {\\n <p>{t(\\\"Loading memory...\\\")}</p>\\n ) : (\\n <>\\n- <div className=\\\"flex justify-between items-center mb-2\\\">\\n- <button\\n- className=\\\"lb-outline-danger\\\"\\n- onClick={handleClearMemory}\\n- >\\n- {t(\\\"Clear Memory\\\")}\\n- </button>\\n- <span className=\\\"text-sm text-gray-500\\\">\\n- {t(\\\"Memory size: {{size}} characters\\\", {\\n- size: JSON.stringify(parsedMemory)\\n- .length,\\n- })}\\n- </span>\\n+ <div className=\\\"flex flex-col md:flex-row justify-between items-start md:items-center mb-2 space-y-2 md:space-y-0\\\">\\n+ <div className=\\\"flex flex-wrap gap-2 items-center\\\">\\n+ <button\\n+ className=\\\"lb-outline-danger\\\"\\n+ onClick={handleClearMemory}\\n+ >\\n+ {t(\\\"Clear Memory\\\")}\\n+ </button>\\n+ <button\\n+ className=\\\"lb-outline-secondary\\\"\\n+ onClick={handleDownloadMemory}\\n+ title={t(\\\"Download memory backup\\\")}\\n+ >\\n+ <FiDownload className=\\\"w-4 h-4\\\" />\\n+ </button>\\n+ <button\\n+ className=\\\"lb-outline-secondary\\\"\\n+ onClick={() =>\\n+ fileInputRef.current?.click()\\n+ }\\n+ title={t(\\\"Upload memory from backup\\\")}\\n+ >\\n+ <FiUpload className=\\\"w-4 h-4\\\" />\\n+ </button>\\n+ <input\\n+ ref={fileInputRef}\\n+ type=\\\"file\\\"\\n+ accept=\\\".json\\\"\\n+ onChange={handleUploadMemory}\\n+ className=\\\"hidden\\\"\\n+ />\\n+ </div>\\n+ <div className=\\\"text-sm text-gray-500 flex flex-wrap gap-2 items-center\\\">\\n+ <span>\\n+ {t(\\\"Memory size: {{size}} characters\\\", {\\n+ size: JSON.stringify(parsedMemory)\\n+ .length,\\n+ })}\\n+ </span>\\n+ {parsedMemory.memoryVersion && (\\n+ <span className=\\\"text-xs text-gray-400\\\">\\n+ (v{parsedMemory.memoryVersion})\\n+ </span>\\n+ )}\\n+ </div>\\n </div>\\n <div className=\\\"border-b border-gray-200\\\">\\n <nav className=\\\"flex -mb-px\\\">\\n@@ -247,6 +367,11 @@ const UserOptions = ({ show, handleClose }) => {\\n className=\\\"lb-input font-mono w-full mt-4\\\"\\n rows={10}\\n />\\n+ {uploadError && (\\n+ <div className=\\\"text-red-500 text-sm mt-2\\\">\\n+ {uploadError}\\n+ </div>\\n+ )}\\n </>\\n )}\\n </div>\\ndiff --git a/src/components/chat/Chat.js b/src/components/chat/Chat.js\\nindex 1197a99..8a684a5 100644\\n--- a/src/components/chat/Chat.js\\n+++ b/src/components/chat/Chat.js\\n@@ -7,6 +7,8 @@ import {\\n useUpdateActiveChat,\\n useGetActiveChat,\\n } from \\\"../../../app/queries/chats\\\";\\n+import { useContext } from \\\"react\\\";\\n+import { AuthContext } from \\\"../../App\\\";\\n \\n const ChatTopMenuDynamic = dynamic(() => import(\\\"./ChatTopMenu\\\"), {\\n loading: () => <div style={{ width: \\\"80px\\\", height: \\\"20px\\\" }}></div>,\\n@@ -16,6 +18,7 @@ function Chat({ viewingChat = null }) {\\n const { t } = useTranslation();\\n const updateActiveChat = useUpdateActiveChat();\\n const { data: chat } = useGetActiveChat();\\n+ const { user } = useContext(AuthContext);\\n const { readOnly } = viewingChat || {};\\n const publicChatOwner = viewingChat?.owner;\\n \\n@@ -74,7 +77,10 @@ function Chat({ viewingChat = null }) {\\n </div>\\n </div>\\n <div className=\\\"grow overflow-auto\\\">\\n- <ChatContent viewingChat={viewingChat} />\\n+ <ChatContent\\n+ viewingChat={viewingChat}\\n+ streamingEnabled={user.streamingEnabled}\\n+ />\\n </div>\\n </div>\\n );\\ndiff --git a/src/components/chat/Chat.scss b/src/components/chat/Chat.scss\\nindex 266ad61..3e7a328 100644\\n--- a/src/components/chat/Chat.scss\\n+++ b/src/components/chat/Chat.scss\\n@@ -213,24 +213,6 @@ html[dir=\\\"ltr\\\"] {\\n height: 87px;\\n background-color: $chat-secondary;\\n }\\n-\\n- // .message-list-container {\\n- // &::before {\\n- // content: '';\\n- // position: absolute;\\n- // width: 5px;\\n- // height: calc(100vh - 30px - 100px);\\n- // cursor: ew-resize;\\n- // }\\n- // }\\n-\\n- .message-container {\\n- // height: calc(100vh - 194px);\\n- }\\n-\\n- // .message-list-container {\\n- // border-inline-start: 1px solid;\\n- // }\\n }\\n \\n .chat-message-container {\\n@@ -694,3 +676,13 @@ html[dir=\\\"ltr\\\"] {\\n box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);\\n }\\n }\\n+\\n+@keyframes pulse {\\n+ 0%,\\n+ 100% {\\n+ opacity: 1;\\n+ }\\n+ 50% {\\n+ opacity: 0.3;\\n+ }\\n+}\\ndiff --git a/src/components/chat/ChatContent.js b/src/components/chat/ChatContent.js\\nindex c0d5396..6b9d747 100644\\n--- a/src/components/chat/ChatContent.js\\n+++ b/src/components/chat/ChatContent.js\\n@@ -7,7 +7,9 @@ import ChatMessages from \\\"./ChatMessages\\\";\\n import { QUERIES } from \\\"../../graphql\\\";\\n import { useGetActiveChat, useUpdateChat } from \\\"../../../app/queries/chats\\\";\\n import { useDeleteAutogenRun } from \\\"../../../app/queries/autogen.js\\\";\\n-import { processImageUrls } from \\\"../../utils/imageUtils\\\";\\n+import { processImageUrls } from \\\"../../utils/imageUtils.mjs\\\";\\n+import { useStreamingMessages } from \\\"../../hooks/useStreamingMessages\\\";\\n+import { useQueryClient } from \\\"@tanstack/react-query\\\";\\n \\n const contextMessageCount = 50;\\n \\n@@ -15,24 +17,48 @@ function ChatContent({\\n displayState = \\\"full\\\",\\n container = \\\"chatpage\\\",\\n viewingChat = null,\\n+ streamingEnabled = false,\\n }) {\\n const { t } = useTranslation();\\n const client = useApolloClient();\\n const { user } = useContext(AuthContext);\\n- const activeChat = useGetActiveChat()?.data;\\n+ const activeChat = useGetActiveChat();\\n+ const updateChatHook = useUpdateChat();\\n+ const deleteAutogenRun = useDeleteAutogenRun();\\n+ const queryClient = useQueryClient();\\n \\n const viewingReadOnlyChat = useMemo(\\n () => displayState === \\\"full\\\" && viewingChat && viewingChat.readOnly,\\n [displayState, viewingChat],\\n );\\n \\n- const chat = viewingReadOnlyChat ? viewingChat : activeChat;\\n+ const chat = viewingReadOnlyChat ? viewingChat : activeChat?.data;\\n const chatId = String(chat?._id);\\n+\\n+ // Simple approach - if we have a chat ID but no messages, refetch once\\n+ useEffect(() => {\\n+ if (\\n+ chat &&\\n+ chat._id &&\\n+ (!chat.messages || chat.messages.length === 0)\\n+ ) {\\n+ queryClient.refetchQueries({ queryKey: [\\\"chat\\\", chat._id] });\\n+ }\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n+ }, [chat?._id]); // Only run when the chat ID changes\\n+\\n const memoizedMessages = useMemo(() => chat?.messages || [], [chat]);\\n- const updateChatHook = useUpdateChat();\\n const publicChatOwner = viewingChat?.owner;\\n const isChatLoading = chat?.isChatLoading;\\n- const deleteAutogenRun = useDeleteAutogenRun();\\n+\\n+ const {\\n+ isStreaming,\\n+ streamingContent,\\n+ stopStreaming,\\n+ setIsStreaming,\\n+ setSubscriptionId,\\n+ clearStreamingState,\\n+ } = useStreamingMessages({ chat, updateChatHook });\\n \\n const handleError = useCallback((error) => {\\n toast.error(error.message);\\n@@ -41,6 +67,9 @@ function ChatContent({\\n const handleSend = useCallback(\\n async (text) => {\\n try {\\n+ // Reset streaming state\\n+ clearStreamingState();\\n+\\n // Optimistic update for the user's message\\n const optimisticUserMessage = {\\n payload: text,\\n@@ -50,13 +79,16 @@ function ChatContent({\\n position: \\\"single\\\",\\n };\\n \\n+ // Use messages directly without processing\\n+ const userMessages = [\\n+ ...(chat?.messages || []),\\n+ optimisticUserMessage,\\n+ ];\\n+\\n // Show the user message immediately\\n await updateChatHook.mutateAsync({\\n chatId: String(chat?._id),\\n- messages: [\\n- ...(chat?.messages || []),\\n- optimisticUserMessage,\\n- ],\\n+ messages: userMessages,\\n isChatLoading: true,\\n });\\n \\n@@ -101,14 +133,31 @@ function ChatContent({\\n title: chat?.title,\\n chatId,\\n codeRequestId: codeRequestIdParam,\\n+ stream: streamingEnabled,\\n };\\n \\n // Perform RAG start query\\n const result = await client.query({\\n- query: QUERIES.RAG_START,\\n+ query: QUERIES.SYS_ENTITY_START,\\n variables,\\n });\\n \\n+ // If streaming is enabled, handle subscription setup\\n+ if (streamingEnabled) {\\n+ const subscriptionId =\\n+ result.data?.sys_entity_start?.result;\\n+ if (subscriptionId) {\\n+ // Set streaming state BEFORE setting subscription ID\\n+ setIsStreaming(true);\\n+\\n+ // Finally set the subscription ID which will trigger the subscription\\n+ setSubscriptionId(subscriptionId);\\n+\\n+ return; // Make sure we return here to prevent non-streaming handling\\n+ }\\n+ }\\n+\\n+ // Non-streaming response handling\\n let resultMessage = \\\"\\\";\\n let tool = null;\\n let newTitle = null;\\n@@ -118,12 +167,14 @@ function ChatContent({\\n try {\\n let resultObj;\\n try {\\n- resultObj = JSON.parse(result.data.rag_start.result);\\n+ resultObj = JSON.parse(\\n+ result.data.sys_entity_start.result,\\n+ );\\n } catch {\\n- resultObj = { response: result.data.rag_start.result };\\n+ resultObj = result.data.sys_entity_start.result;\\n }\\n- resultMessage = resultObj?.response || resultObj;\\n- tool = result.data.rag_start.tool;\\n+ resultMessage = resultObj;\\n+ tool = result.data.sys_entity_start.tool;\\n if (tool) {\\n const toolObj = JSON.parse(tool);\\n toolCallbackName = toolObj?.toolCallbackName;\\n@@ -189,9 +240,12 @@ function ChatContent({\\n sender: \\\"labeeb\\\",\\n });\\n \\n+ // Use messages directly without processing\\n+ const currentMessagesToUpdate = currentMessages;\\n+\\n await updateChatHook.mutateAsync({\\n chatId: String(chat?._id),\\n- messages: currentMessages,\\n+ messages: currentMessagesToUpdate,\\n ...(newTitle && { title: newTitle }),\\n isChatLoading: !!toolCallbackName,\\n ...(toolCallbackId && { toolCallbackId }),\\n@@ -249,41 +303,47 @@ function ChatContent({\\n sender: \\\"labeeb\\\",\\n });\\n \\n+ // Use messages directly without processing\\n+ const finalMessagesToUpdate = finalMessages;\\n+\\n await updateChatHook.mutateAsync({\\n chatId: String(chat?._id),\\n- messages: finalMessages,\\n+ messages: finalMessagesToUpdate,\\n isChatLoading: false,\\n });\\n }\\n } catch (error) {\\n+ setIsStreaming(false);\\n handleError(error);\\n- // Update to include both the original user message and the error message\\n+\\n+ // Use error messages directly without processing\\n+ const errorMessagesToUpdate = [\\n+ ...(chat?.messages || []),\\n+ {\\n+ payload: text,\\n+ sender: \\\"user\\\",\\n+ sentTime: \\\"just now\\\",\\n+ direction: \\\"outgoing\\\",\\n+ position: \\\"single\\\",\\n+ },\\n+ {\\n+ payload: t(\\n+ \\\"Something went wrong trying to respond to your request. Please try something else or start over to continue.\\\",\\n+ ),\\n+ sender: \\\"labeeb\\\",\\n+ sentTime: \\\"just now\\\",\\n+ direction: \\\"incoming\\\",\\n+ position: \\\"single\\\",\\n+ },\\n+ ];\\n+\\n await updateChatHook.mutateAsync({\\n chatId: String(chat?._id),\\n- messages: [\\n- ...(chat?.messages || []),\\n- {\\n- payload: text,\\n- sender: \\\"user\\\",\\n- sentTime: \\\"just now\\\",\\n- direction: \\\"outgoing\\\",\\n- position: \\\"single\\\",\\n- },\\n- {\\n- payload: t(\\n- \\\"Something went wrong trying to respond to your request. Please try something else or start over to continue.\\\",\\n- ),\\n- sender: \\\"labeeb\\\",\\n- sentTime: \\\"just now\\\",\\n- direction: \\\"incoming\\\",\\n- position: \\\"single\\\",\\n- },\\n- ],\\n+ messages: errorMessagesToUpdate,\\n isChatLoading: false,\\n });\\n }\\n },\\n- // eslint-disable-next-line react-hooks/exhaustive-deps\\n [\\n chat,\\n updateChatHook,\\n@@ -291,8 +351,13 @@ function ChatContent({\\n user,\\n memoizedMessages,\\n handleError,\\n- chatId,\\n t,\\n+ chatId,\\n+ clearStreamingState,\\n+ deleteAutogenRun,\\n+ setIsStreaming,\\n+ setSubscriptionId,\\n+ streamingEnabled,\\n ],\\n );\\n \\n@@ -323,6 +388,9 @@ function ChatContent({\\n container={container}\\n displayState={displayState}\\n chatId={chatId}\\n+ isStreaming={isStreaming}\\n+ streamingContent={streamingContent}\\n+ onStopStreaming={stopStreaming}\\n />\\n );\\n }\\ndiff --git a/src/components/chat/ChatMessage.js b/src/components/chat/ChatMessage.js\\nindex 033f7c0..7a6334c 100644\\n--- a/src/components/chat/ChatMessage.js\\n+++ b/src/components/chat/ChatMessage.js\\n@@ -12,6 +12,7 @@ import rehypeRaw from \\\"rehype-raw\\\";\\n import remarkMath from \\\"remark-math\\\";\\n import \\\"katex/dist/katex.min.css\\\";\\n import { visit } from \\\"unist-util-visit\\\";\\n+import ChatImage from \\\"../images/ChatImage\\\";\\n \\n function transformToCitation(content) {\\n return content\\n@@ -56,10 +57,8 @@ function customMarkdownDirective() {\\n \\n function convertMessageToMarkdown(message) {\\n const { payload, tool } = message;\\n-\\n const citations = tool ? JSON.parse(tool).citations : null;\\n-\\n- let componentIndex = 0;\\n+ let componentIndex = 0; // Counter for code blocks\\n \\n if (typeof payload !== \\\"string\\\") {\\n return payload;\\n@@ -93,6 +92,7 @@ function convertMessageToMarkdown(message) {\\n p({ node, ...rest }) {\\n return <div className=\\\"mb-1\\\" {...rest} />;\\n },\\n+ img: ChatImage,\\n cd_inline_emotion({ children, emotion }) {\\n return (\\n <InlineEmotionDisplay emotion={emotion}>\\ndiff --git a/src/components/chat/ChatMessages.js b/src/components/chat/ChatMessages.js\\nindex 1035a06..5bb49b3 100644\\n--- a/src/components/chat/ChatMessages.js\\n+++ b/src/components/chat/ChatMessages.js\\n@@ -1,15 +1,9 @@\\n-import React, { useContext, useCallback, useMemo } from \\\"react\\\";\\n+import React, { useContext, useCallback, useMemo, useRef } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n-import { AiOutlineReload, AiOutlineSave } from \\\"react-icons/ai\\\";\\n import dynamic from \\\"next/dynamic\\\";\\n-import { useApolloClient } from \\\"@apollo/client\\\";\\n-import { useAddChat } from \\\"../../../app/queries/chats\\\";\\n-import { handleSaveChat } from \\\"./SaveChat\\\";\\n import { AuthContext } from \\\"../../App.js\\\";\\n import MessageInput from \\\"./MessageInput\\\";\\n import MessageList from \\\"./MessageList\\\";\\n-import config from \\\"../../../config\\\";\\n-import { convertMessageToMarkdown } from \\\"./ChatMessage\\\";\\n \\n const ChatTopMenuDynamic = dynamic(() => import(\\\"./ChatTopMenu\\\"));\\n \\n@@ -22,89 +16,50 @@ const ChatMessages = React.memo(function ChatMessages({\\n viewingReadOnlyChat,\\n publicChatOwner,\\n chatId,\\n+ streamingContent,\\n+ isStreaming,\\n+ onStopStreaming,\\n }) {\\n const { user } = useContext(AuthContext);\\n- const { aiName } = user;\\n const { t } = useTranslation();\\n- const client = useApolloClient();\\n- const addChat = useAddChat();\\n-\\n- const processedMessages = useMemo(() => {\\n- return messages.map((m, index) => {\\n- const baseMessage = {\\n- ...m,\\n- text: m.payload,\\n- };\\n-\\n- if (m.sender === \\\"labeeb\\\") {\\n- return {\\n- ...baseMessage,\\n- payload: (\\n- <React.Fragment key={`outer-${m?.id || index}`}>\\n- {convertMessageToMarkdown(m)}\\n- </React.Fragment>\\n- ),\\n- };\\n- }\\n- return baseMessage;\\n- });\\n- }, [messages]);\\n-\\n- const handleSaveChatCallback = useCallback(() => {\\n- handleSaveChat(messages, client, addChat);\\n- }, [messages, client, addChat]);\\n+ const { aiName } = user;\\n+ const messageListRef = useRef(null);\\n \\n const handleSendCallback = useCallback(\\n- (message) => {\\n- onSend(message);\\n+ (text) => {\\n+ // Reset scroll state when user sends a message\\n+ messageListRef.current?.scrollBottomRef?.current?.resetScrollState();\\n+ onSend(text);\\n },\\n [onSend],\\n );\\n \\n const inputPlaceholder = useMemo(() => {\\n- return container === \\\"chatbox\\\"\\n- ? t(`Send message`)\\n- : `${t(\\\"Send a message to\\\")} ${t(aiName || config?.chat?.botName)}`;\\n- }, [container, t, aiName]);\\n+ if (container === \\\"codebox\\\") {\\n+ return t(\\\"Ask me to write, explain, or fix code\\\");\\n+ }\\n+ return t(\\\"Send a message\\\");\\n+ }, [container, t]);\\n \\n return (\\n- <div className=\\\"h-full flex flex-col gap-3\\\">\\n- <div className=\\\"grow overflow-auto flex flex-col chat-content\\\">\\n- <div className=\\\"hidden justify-between items-center px-3 pb-2 text-xs [.docked_&]:flex\\\">\\n- <ChatTopMenuDynamic\\n- displayState={displayState}\\n- publicChatOwner={publicChatOwner}\\n- />\\n- {false && processedMessages.length > 0 && (\\n- <div className=\\\"flex gap-2\\\">\\n- <button\\n- className=\\\"flex gap-1 items-center hover:underline hover:text-sky-500 active:text-sky-700\\\"\\n- onClick={() => {\\n- if (window.confirm(t(\\\"Are you sure?\\\"))) {\\n- console.log(\\\"Reset chat\\\");\\n- }\\n- }}\\n- >\\n- <AiOutlineReload />\\n- {t(\\\"Reset chat\\\")}\\n- </button>\\n- <button\\n- className=\\\"flex gap-1 items-center hover:underline hover:text-sky-500 active:text-sky-700\\\"\\n- onClick={handleSaveChatCallback}\\n- >\\n- <AiOutlineSave />\\n- {t(\\\"Save chat\\\")}\\n- </button>\\n- </div>\\n- )}\\n- </div>\\n- <div className=\\\"grow overflow-auto chat-message-list\\\">\\n- <MessageList\\n- messages={processedMessages}\\n- loading={loading}\\n- chatId={chatId}\\n- />\\n- </div>\\n+ <div className=\\\"flex flex-col h-full\\\">\\n+ <div className=\\\"hidden justify-between items-center px-3 pb-2 text-xs [.docked_&]:flex\\\">\\n+ <ChatTopMenuDynamic\\n+ displayState={displayState}\\n+ publicChatOwner={publicChatOwner}\\n+ />\\n+ </div>\\n+ <div className=\\\"grow overflow-auto chat-message-list flex flex-col\\\">\\n+ <MessageList\\n+ ref={messageListRef}\\n+ messages={messages}\\n+ loading={loading && !isStreaming}\\n+ chatId={chatId}\\n+ bot={container === \\\"codebox\\\" ? \\\"code\\\" : \\\"chat\\\"}\\n+ streamingContent={streamingContent}\\n+ isStreaming={isStreaming}\\n+ aiName={aiName}\\n+ />\\n </div>\\n <div>\\n <MessageInput\\n@@ -115,6 +70,8 @@ const ChatMessages = React.memo(function ChatMessages({\\n container={container}\\n displayState={displayState}\\n onSend={handleSendCallback}\\n+ isStreaming={isStreaming}\\n+ onStopStreaming={onStopStreaming}\\n />\\n </div>\\n </div>\\ndiff --git a/src/components/chat/MessageInput.js b/src/components/chat/MessageInput.js\\nindex a73a74d..db40b1f 100644\\n--- a/src/components/chat/MessageInput.js\\n+++ b/src/components/chat/MessageInput.js\\n@@ -1,5 +1,5 @@\\n import \\\"highlight.js/styles/github.css\\\";\\n-import { useContext, useState } from \\\"react\\\";\\n+import { useContext, useState, useEffect } from \\\"react\\\";\\n import { RiSendPlane2Fill } from \\\"react-icons/ri\\\";\\n import TextareaAutosize from \\\"react-textarea-autosize\\\";\\n import classNames from \\\"../../../app/utils/class-names\\\";\\n@@ -14,8 +14,8 @@ import {\\n loadingError,\\n } from \\\"../../stores/fileUploadSlice\\\";\\n import { FaFileCirclePlus } from \\\"react-icons/fa6\\\";\\n-import { IoCloseCircle } from \\\"react-icons/io5\\\";\\n-import { getFilename, isDocumentUrl, isMediaUrl } from \\\"./MyFilePond\\\";\\n+import { IoCloseCircle, IoStopCircle } from \\\"react-icons/io5\\\";\\n+import { getFilename, isDocumentUrl, isMediaUrl } from \\\"../../utils/mediaUtils\\\";\\n import { AuthContext } from \\\"../../App\\\";\\n import { useAddDocument } from \\\"../../../app/queries/uploadedDocs\\\";\\n import {\\n@@ -34,25 +34,41 @@ function MessageInput({\\n enableRag,\\n placeholder,\\n viewingReadOnlyChat,\\n+ isStreaming,\\n+ onStopStreaming,\\n }) {\\n- const [inputValue, setInputValue] = useState(\\\"\\\");\\n- const [urlsData, setUrlsData] = useState([]);\\n- const [files, setFiles] = useState([]);\\n- const [showFileUpload, setShowFileUpload] = useState(false);\\n- const client = useApolloClient();\\n- const { user } = useContext(AuthContext);\\n+ const activeChatId = useGetActiveChatId();\\n+ const activeChat = useGetActiveChat().data;\\n+\\n+ const { user, userState, debouncedUpdateUserState } =\\n+ useContext(AuthContext);\\n const contextId = user?.contextId;\\n const dispatch = useDispatch();\\n+ const client = useApolloClient();\\n const [isUploadingMedia, setIsUploadingMedia] = useState(false);\\n const addDocument = useAddDocument();\\n- const handleInputChange = (event) => {\\n- setInputValue(event.target.value);\\n- };\\n- const activeChatId = useGetActiveChatId();\\n- const activeChat = useGetActiveChat().data;\\n const codeRequestId = activeChat?.codeRequestId;\\n const apolloClient = useApolloClient();\\n \\n+ // Only set input value on initial mount or chat change\\n+ useEffect(() => {\\n+ if (\\n+ activeChatId &&\\n+ userState?.chatInputs &&\\n+ userState.chatInputs[activeChatId]\\n+ ) {\\n+ setInputValue(userState.chatInputs[activeChatId]);\\n+ } else {\\n+ setInputValue(\\\"\\\");\\n+ }\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n+ }, [activeChatId]); // Only depend on activeChatId, not userState\\n+\\n+ const [inputValue, setInputValue] = useState(\\\"\\\");\\n+ const [urlsData, setUrlsData] = useState([]);\\n+ const [files, setFiles] = useState([]);\\n+ const [showFileUpload, setShowFileUpload] = useState(false);\\n+\\n const prepareMessage = (inputText) => {\\n return [\\n JSON.stringify({ type: \\\"text\\\", text: inputText }),\\n@@ -76,6 +92,20 @@ function MessageInput({\\n ];\\n };\\n \\n+ const handleInputChange = (event) => {\\n+ const newValue = event.target.value;\\n+ setInputValue(newValue);\\n+\\n+ if (activeChatId) {\\n+ debouncedUpdateUserState((prevState) => ({\\n+ chatInputs: {\\n+ ...(prevState?.chatInputs || {}),\\n+ [activeChatId]: newValue,\\n+ },\\n+ }));\\n+ }\\n+ };\\n+\\n const handleFormSubmit = (event) => {\\n event.preventDefault();\\n if (codeRequestId && inputValue) {\\n@@ -89,6 +119,14 @@ function MessageInput({\\n });\\n \\n setInputValue(\\\"\\\");\\n+ if (activeChatId) {\\n+ debouncedUpdateUserState((prevState) => ({\\n+ chatInputs: {\\n+ ...(prevState?.chatInputs || {}),\\n+ [activeChatId]: \\\"\\\",\\n+ },\\n+ }));\\n+ }\\n return;\\n }\\n if (!loading && inputValue) {\\n@@ -97,6 +135,15 @@ function MessageInput({\\n setInputValue(\\\"\\\");\\n setFiles([]);\\n setUrlsData([]);\\n+\\n+ if (activeChatId) {\\n+ debouncedUpdateUserState((prevState) => ({\\n+ chatInputs: {\\n+ ...(prevState?.chatInputs || {}),\\n+ [activeChatId]: \\\"\\\",\\n+ },\\n+ }));\\n+ }\\n }\\n };\\n \\n@@ -165,7 +212,7 @@ function MessageInput({\\n setIsUploadingMedia={setIsUploadingMedia}\\n />\\n )}\\n- <div className=\\\"rounded-md border dark:border-zinc-200\\\">\\n+ <div className=\\\"rounded-md border dark:border-zinc-200 mt-3\\\">\\n <form\\n onSubmit={handleFormSubmit}\\n className=\\\"flex items-center rounded-md dark:bg-zinc-100\\\"\\n@@ -221,22 +268,34 @@ function MessageInput({\\n </div>\\n <div className=\\\" pe-4 ps-3 dark:bg-zinc-100 self-stretch flex rounded-e\\\">\\n <div className=\\\"pt-4\\\">\\n- <button\\n- type=\\\"submit\\\"\\n- disabled={\\n- codeRequestId\\n- ? false\\n- : loading ||\\n- inputValue === \\\"\\\" ||\\n- isUploadingMedia ||\\n- viewingReadOnlyChat\\n- }\\n- className={classNames(\\n- \\\"text-base rtl:rotate-180 text-emerald-600 hover:text-emerald-600 disabled:text-gray-300 active:text-gray-800 dark:bg-zinc-100\\\",\\n- )}\\n- >\\n- <RiSendPlane2Fill />\\n- </button>\\n+ {isStreaming ? (\\n+ <button\\n+ type=\\\"button\\\"\\n+ onClick={onStopStreaming}\\n+ className={classNames(\\n+ \\\"text-base text-red-600 hover:text-red-700 active:text-red-800 dark:bg-zinc-100\\\",\\n+ )}\\n+ >\\n+ <IoStopCircle />\\n+ </button>\\n+ ) : (\\n+ <button\\n+ type=\\\"submit\\\"\\n+ disabled={\\n+ codeRequestId\\n+ ? false\\n+ : loading ||\\n+ inputValue === \\\"\\\" ||\\n+ isUploadingMedia ||\\n+ viewingReadOnlyChat\\n+ }\\n+ className={classNames(\\n+ \\\"text-base rtl:rotate-180 text-emerald-600 hover:text-emerald-600 disabled:text-gray-300 active:text-gray-800 dark:bg-zinc-100\\\",\\n+ )}\\n+ >\\n+ <RiSendPlane2Fill />\\n+ </button>\\n+ )}\\n </div>\\n </div>\\n </form>\\ndiff --git a/src/components/chat/MessageList.js b/src/components/chat/MessageList.js\\nindex 77d060b..9bc6626 100644\\n--- a/src/components/chat/MessageList.js\\n+++ b/src/components/chat/MessageList.js\\n@@ -1,5 +1,10 @@\\n import i18next from \\\"i18next\\\";\\n-import React, { useEffect, useContext, useCallback } from \\\"react\\\";\\n+import React, {\\n+ useEffect,\\n+ useCallback,\\n+ useRef,\\n+ useImperativeHandle,\\n+} from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n import { AiFillFilePdf, AiFillFileText, AiOutlineRobot } from \\\"react-icons/ai\\\";\\n import { FaUserCircle } from \\\"react-icons/fa\\\";\\n@@ -13,30 +18,38 @@ import {\\n getFilename,\\n isAudioUrl,\\n isVideoUrl,\\n-} from \\\"./MyFilePond\\\";\\n+} from \\\"../../utils/mediaUtils\\\";\\n import CopyButton from \\\"../CopyButton\\\";\\n-import { AuthContext } from \\\"../../App.js\\\";\\n import { useGetActiveChat, useUpdateChat } from \\\"../../../app/queries/chats\\\";\\n import ProgressUpdate from \\\"../editor/ProgressUpdate\\\";\\n import { useGetAutogenRun } from \\\"../../../app/queries/autogen\\\";\\n+import StreamingMessage from \\\"./StreamingMessage\\\";\\n+import ChatImage from \\\"../images/ChatImage\\\";\\n \\n-const getLoadState = (message) => {\\n- const hasImage =\\n- Array.isArray(message.payload) &&\\n- message.payload.some((p) => {\\n- try {\\n- const obj = JSON.parse(p);\\n- return obj.type === \\\"image_url\\\";\\n- } catch (e) {\\n- return false;\\n- }\\n- });\\n+const hasImages = (message) => {\\n+ if (!Array.isArray(message.payload)) return false;\\n \\n- if (hasImage) {\\n- return false;\\n- } else {\\n- return true;\\n- }\\n+ return message.payload.some((p) => {\\n+ try {\\n+ const obj = JSON.parse(p);\\n+ return obj.type === \\\"image_url\\\";\\n+ } catch (e) {\\n+ return false;\\n+ }\\n+ });\\n+};\\n+\\n+const countImages = (message) => {\\n+ if (!Array.isArray(message.payload)) return 0;\\n+\\n+ return message.payload.reduce((count, p) => {\\n+ try {\\n+ const obj = JSON.parse(p);\\n+ return obj.type === \\\"image_url\\\" ? count + 1 : count;\\n+ } catch (e) {\\n+ return count;\\n+ }\\n+ }, 0);\\n };\\n \\n const getToolMetadata = (toolName, t) => {\\n@@ -75,422 +88,649 @@ const parseToolData = (toolString) => {\\n }\\n };\\n \\n-// Displays the list of messages and a message input box.\\n-function MessageList({ messages, bot, loading, chatId }) {\\n- const { user } = useContext(AuthContext);\\n- const { aiName } = user;\\n- const { language } = i18next;\\n- const { getLogo } = config.global;\\n- const { t } = useTranslation();\\n- const [messageLoadState, setMessageLoadState] = React.useState(\\n- messages.map((m) => {\\n- return {\\n- id: m.id,\\n- loaded: getLoadState(m),\\n- };\\n- }),\\n- );\\n- const chat = useGetActiveChat()?.data;\\n- const updateChat = useUpdateChat();\\n- const codeRequestId = chat?.codeRequestId;\\n- const getAutogenRun = useGetAutogenRun(codeRequestId);\\n-\\n- const setCodeRequestFinalData = useCallback(\\n- (data) => {\\n- const message = {\\n- payload: data,\\n- sender: \\\"labeeb\\\",\\n- sentTime: \\\"just now\\\",\\n- direction: \\\"incoming\\\",\\n- position: \\\"single\\\",\\n- tool: '{\\\"toolUsed\\\":\\\"coding\\\"}',\\n- };\\n-\\n- updateChat.mutateAsync({\\n- chatId,\\n- codeRequestId: null,\\n- isChatLoading: false,\\n- messages: [...chat.messages, message],\\n- });\\n- },\\n- [chat?.messages, chatId, updateChat],\\n+const getYoutubeEmbedUrl = (url) => {\\n+ try {\\n+ const urlObj = new URL(url);\\n+ if (urlObj.hostname === \\\"youtu.be\\\") {\\n+ const videoId = urlObj.pathname.slice(1);\\n+ return `https://www.youtube.com/embed/${videoId}`;\\n+ } else if (\\n+ urlObj.hostname === \\\"youtube.com\\\" ||\\n+ urlObj.hostname === \\\"www.youtube.com\\\"\\n+ ) {\\n+ const videoId = urlObj.searchParams.get(\\\"v\\\");\\n+ return `https://www.youtube.com/embed/${videoId}`;\\n+ }\\n+ } catch (err) {\\n+ return null;\\n+ }\\n+ return null;\\n+};\\n+\\n+// Add memoized YouTube component\\n+const MemoizedYouTubeEmbed = React.memo(({ url, onLoad }) => {\\n+ return (\\n+ <iframe\\n+ title={`YouTube video ${url.split(\\\"/\\\").pop()}`}\\n+ onLoad={onLoad}\\n+ src={url}\\n+ className=\\\"w-full max-h-[20%] max-w-[60%] [.docked_&]:max-w-[90%] rounded border-0 my-2 shadow-lg dark:shadow-black/30\\\"\\n+ style={{\\n+ minWidth: \\\"360px\\\",\\n+ width: \\\"640px\\\",\\n+ aspectRatio: \\\"16/9\\\",\\n+ backgroundColor: \\\"transparent\\\",\\n+ }}\\n+ allowFullScreen\\n+ allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\\\"\\n+ />\\n );\\n+});\\n \\n- useEffect(() => {\\n- const data = getAutogenRun?.data?.data?.data;\\n- if (data) {\\n- setCodeRequestFinalData(data);\\n+// Add this near the top of the file, after imports:\\n+const MemoizedMarkdownMessage = React.memo(\\n+ ({ message }) => {\\n+ return convertMessageToMarkdown(message);\\n+ },\\n+ (prevProps, nextProps) => {\\n+ // If messages are completely identical, no need to re-render\\n+ if (prevProps.message === nextProps.message) {\\n+ return true;\\n }\\n- }, [getAutogenRun?.data?.data, setCodeRequestFinalData]);\\n \\n- const messageLoadStateRef = React.useRef(messageLoadState);\\n+ // If payloads are strings and identical, no need to re-render\\n+ if (\\n+ typeof prevProps.message.payload === \\\"string\\\" &&\\n+ typeof nextProps.message.payload === \\\"string\\\" &&\\n+ prevProps.message.payload === nextProps.message.payload\\n+ ) {\\n+ return true;\\n+ }\\n \\n- useEffect(() => {\\n- // merge load state\\n- const newMessageLoadState = messages.map((m) => {\\n- const existing = messageLoadStateRef.current.find(\\n- (mls) => mls.id === m.id,\\n- );\\n- if (existing) {\\n- return existing;\\n+ // For array payloads, we need to compare each item\\n+ if (\\n+ Array.isArray(prevProps.message.payload) &&\\n+ Array.isArray(nextProps.message.payload)\\n+ ) {\\n+ if (\\n+ prevProps.message.payload.length !==\\n+ nextProps.message.payload.length\\n+ ) {\\n+ return false;\\n }\\n- return {\\n- id: m.id,\\n- loaded: getLoadState(m),\\n- };\\n- });\\n-\\n- setMessageLoadState(newMessageLoadState);\\n- }, [messages]);\\n-\\n- let rowHeight = \\\"h-12 [.docked_&]:h-10\\\";\\n- let basis =\\n- \\\"min-w-[3rem] basis-12 [.docked_&]:basis-10 [.docked_&]:min-w-[2.5rem]\\\";\\n- let buttonWidthClass = \\\"w-12 [.docked_&]:w-10\\\";\\n- const botName =\\n- bot === \\\"code\\\"\\n- ? config?.code?.botName\\n- : aiName || config?.chat?.botName;\\n-\\n- const renderMessage = (message) => {\\n- let avatar;\\n- const toolData = parseToolData(message.tool);\\n-\\n- if (message.sender === \\\"labeeb\\\") {\\n- avatar = toolData?.avatarImage ? (\\n- <img\\n- src={toolData.avatarImage}\\n- alt=\\\"Tool Avatar\\\"\\n- className={classNames(\\n- basis,\\n- \\\"p-1\\\",\\n- buttonWidthClass,\\n- rowHeight,\\n- \\\"rounded-full object-cover\\\",\\n- )}\\n- />\\n- ) : bot === \\\"code\\\" ? (\\n- <AiOutlineRobot\\n- className={classNames(\\n- rowHeight,\\n- buttonWidthClass,\\n- \\\"px-3\\\",\\n- \\\"text-gray-400\\\",\\n- )}\\n- />\\n- ) : (\\n- <img\\n- src={getLogo(language)}\\n- alt=\\\"Logo\\\"\\n- className={classNames(\\n- basis,\\n- \\\"p-2\\\",\\n- buttonWidthClass,\\n- rowHeight,\\n- )}\\n- />\\n- );\\n \\n- return (\\n- <div\\n- key={message.id}\\n- className=\\\"flex bg-sky-50 ps-1 pt-1 relative group\\\"\\n- >\\n- <div className=\\\"flex items-center gap-2 absolute top-3 end-3\\\">\\n- {toolData?.toolUsed && (\\n- <div className=\\\"tool-badge inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-sky-50 border border-sky-100 text-xs text-sky-600 font-medium w-fit\\\">\\n- <span className=\\\"tool-icon\\\">\\n- {getToolMetadata(toolData.toolUsed, t).icon}\\n- </span>\\n- <span className=\\\"tool-name\\\">\\n- {t(\\\"Used {{tool}} tool\\\", {\\n- tool: getToolMetadata(\\n- toolData.toolUsed,\\n- t,\\n- ).translatedName,\\n- })}\\n- </span>\\n- </div>\\n- )}\\n- <CopyButton\\n- item={message.text}\\n- className=\\\"copy-button opacity-0 group-hover:opacity-60 hover:opacity-100 transition-opacity\\\"\\n- />\\n- </div>\\n+ // Compare each item in the array\\n+ return prevProps.message.payload.every((item, index) => {\\n+ const nextItem = nextProps.message.payload[index];\\n+ try {\\n+ const prevObj =\\n+ typeof item === \\\"string\\\" ? JSON.parse(item) : item;\\n+ const nextObj =\\n+ typeof nextItem === \\\"string\\\"\\n+ ? JSON.parse(nextItem)\\n+ : nextItem;\\n \\n- <div className={classNames(basis)}>{avatar}</div>\\n- <div\\n- className={classNames(\\n- \\\"px-1 pb-3 pt-2 [.docked_&]:px-0 [.docked_&]:py-3 w-full\\\",\\n- )}\\n- >\\n- <div className=\\\"flex flex-col\\\">\\n- <div className=\\\"font-semibold\\\">{t(botName)}</div>\\n- <div\\n- className=\\\"chat-message-bot relative break-words\\\"\\n- ref={(el) => {\\n- if (el) {\\n- const images =\\n- el.getElementsByTagName(\\\"img\\\");\\n- Array.from(images).forEach((img) => {\\n- if (!img.complete) {\\n- img.addEventListener(\\n- \\\"load\\\",\\n- () =>\\n- handleMessageLoad(\\n- message.id,\\n- ),\\n- );\\n- }\\n- });\\n+ // For image URLs, only compare the base URL without query parameters\\n+ if (\\n+ prevObj.type === \\\"image_url\\\" &&\\n+ nextObj.type === \\\"image_url\\\"\\n+ ) {\\n+ const prevUrl = new URL(\\n+ prevObj.url ||\\n+ prevObj.image_url?.url ||\\n+ prevObj.gcs,\\n+ ).pathname;\\n+ const nextUrl = new URL(\\n+ nextObj.url ||\\n+ nextObj.image_url?.url ||\\n+ nextObj.gcs,\\n+ ).pathname;\\n+ return prevUrl === nextUrl;\\n+ }\\n+\\n+ return JSON.stringify(prevObj) === JSON.stringify(nextObj);\\n+ } catch (e) {\\n+ // If JSON parsing fails, compare as strings\\n+ return item === nextItem;\\n+ }\\n+ });\\n+ }\\n+\\n+ // Default to re-rendering if we can't determine equality\\n+ return false;\\n+ },\\n+);\\n+\\n+// Create a memoized component for the static message list content\\n+const MessageListContent = React.memo(function MessageListContent({\\n+ messages,\\n+ renderMessage,\\n+ handleMessageLoad,\\n+ isVideoUrl,\\n+ isAudioUrl,\\n+ getExtension,\\n+ getFilename,\\n+ getYoutubeEmbedUrl,\\n+}) {\\n+ return messages.map((message, index) => {\\n+ const newMessage = { ...message };\\n+ if (!newMessage.id) {\\n+ newMessage.id = newMessage._id || index;\\n+ }\\n+ let display;\\n+ if (Array.isArray(newMessage.payload)) {\\n+ const arr = newMessage.payload.map((t, index2) => {\\n+ try {\\n+ const obj = JSON.parse(t);\\n+ if (obj.type === \\\"text\\\") {\\n+ return obj.text;\\n+ } else if (obj.type === \\\"image_url\\\") {\\n+ const src = obj?.url || obj?.image_url?.url || obj?.gcs;\\n+ if (isVideoUrl(src)) {\\n+ const youtubeEmbedUrl = getYoutubeEmbedUrl(src);\\n+ if (youtubeEmbedUrl) {\\n+ return (\\n+ <MemoizedYouTubeEmbed\\n+ key={youtubeEmbedUrl}\\n+ url={youtubeEmbedUrl}\\n+ onLoad={() =>\\n+ handleMessageLoad(newMessage.id)\\n+ }\\n+ />\\n+ );\\n+ }\\n+ return (\\n+ <video\\n+ onLoadedData={() =>\\n+ handleMessageLoad(newMessage.id)\\n }\\n- }}\\n- >\\n- {message.payload}\\n+ key={`video-${index}-${index2}`}\\n+ src={src}\\n+ className=\\\"max-h-[20%] max-w-[60%] [.docked_&]:max-w-[90%] rounded border-0 my-2 shadow-lg dark:shadow-black/30\\\"\\n+ style={{\\n+ backgroundColor: \\\"transparent\\\",\\n+ }}\\n+ controls\\n+ preload=\\\"metadata\\\"\\n+ playsInline\\n+ />\\n+ );\\n+ } else if (isAudioUrl(src)) {\\n+ return (\\n+ <audio\\n+ onLoadedData={() =>\\n+ handleMessageLoad(newMessage.id)\\n+ }\\n+ key={`audio-${index}-${index2}`}\\n+ src={src}\\n+ className=\\\"max-h-[20%] max-w-[100%] [.docked_&]:max-w-[80%] rounded-md border bg-white p-1 my-2 dark:border-neutral-700 dark:bg-neutral-800 shadow-lg dark:shadow-black/30\\\"\\n+ controls\\n+ />\\n+ );\\n+ }\\n+\\n+ if (getExtension(src) === \\\".pdf\\\") {\\n+ const filename = decodeURIComponent(\\n+ getFilename(src),\\n+ );\\n+ return (\\n+ <a\\n+ key={`pdf-${index}-${index2}`}\\n+ className=\\\"bg-neutral-100 py-2 ps-2 pe-4 m-2 shadow-md rounded-lg border flex gap-2 items-center\\\"\\n+ onLoad={() =>\\n+ handleMessageLoad(newMessage.id)\\n+ }\\n+ href={src}\\n+ target=\\\"_blank\\\"\\n+ rel=\\\"noopener noreferrer\\\"\\n+ >\\n+ <AiFillFilePdf\\n+ size={40}\\n+ className=\\\"text-red-600 dark:text-red-400\\\"\\n+ />\\n+ {filename}\\n+ </a>\\n+ );\\n+ }\\n+\\n+ if (getExtension(src) === \\\".txt\\\") {\\n+ const filename = decodeURIComponent(\\n+ getFilename(src),\\n+ );\\n+ return (\\n+ <a\\n+ key={`txt-${index}-${index2}`}\\n+ className=\\\"bg-neutral-100 py-2 ps-2 pe-4 m-2 shadow-md rounded-lg border flex gap-2 items-center\\\"\\n+ onLoad={() =>\\n+ handleMessageLoad(newMessage.id)\\n+ }\\n+ href={src}\\n+ target=\\\"_blank\\\"\\n+ rel=\\\"noopener noreferrer\\\"\\n+ >\\n+ <AiFillFileText\\n+ size={40}\\n+ className=\\\"text-red-600 dark:text-red-400\\\"\\n+ />\\n+ {filename}\\n+ </a>\\n+ );\\n+ }\\n+\\n+ return (\\n+ <div key={src}>\\n+ <ChatImage\\n+ src={src}\\n+ alt=\\\"uploadedimage\\\"\\n+ onLoad={() =>\\n+ handleMessageLoad(newMessage.id)\\n+ }\\n+ />\\n </div>\\n- </div>\\n- </div>\\n- </div>\\n- );\\n+ );\\n+ }\\n+ return null;\\n+ } catch (e) {\\n+ console.error(\\\"Invalid JSON:\\\", t);\\n+ return t;\\n+ }\\n+ });\\n+ display = <>{arr}</>;\\n } else {\\n- avatar = (\\n- <FaUserCircle\\n- className={classNames(\\n- rowHeight,\\n- buttonWidthClass,\\n- \\\"p-2\\\",\\n- \\\"text-gray-300\\\",\\n- )}\\n- />\\n- );\\n- return (\\n- <div\\n- key={message.id}\\n- className=\\\"flex ps-1 pt-1 relative [&_button]:hidden [&_button]:hover:block\\\"\\n- >\\n- <CopyButton\\n- item={message.text}\\n- className=\\\"absolute top-3 end-3 opacity-60 hover:opacity-100\\\"\\n- />\\n- <div className={classNames(basis, \\\"py-0\\\")}>{avatar}</div>\\n- <div\\n- className={classNames(\\n- \\\"px-1 pb-3 pt-2 [.docked_&]:px-0 [.docked_&]:py-3\\\",\\n- )}\\n- >\\n- <div className=\\\"font-semibold\\\">{t(\\\"You\\\")}</div>\\n- <pre className=\\\"chat-message-user\\\">\\n- {message.payload}\\n- </pre>\\n- </div>\\n- </div>\\n- );\\n+ display = newMessage.payload;\\n }\\n- };\\n \\n- const handleMessageLoad = (id) => {\\n- setMessageLoadState((prev) => {\\n- return prev.map((m) => {\\n- if (m.id === id) {\\n- return {\\n- id: m.id,\\n- loaded: true,\\n- };\\n- }\\n- return m;\\n+ return (\\n+ <div key={newMessage.id}>\\n+ {renderMessage({ ...newMessage, payload: display })}\\n+ </div>\\n+ );\\n+ });\\n+});\\n+\\n+// Displays the list of messages and a message input box.\\n+const MessageList = React.memo(\\n+ React.forwardRef(function MessageList(\\n+ {\\n+ messages,\\n+ bot,\\n+ loading,\\n+ chatId,\\n+ streamingContent,\\n+ isStreaming,\\n+ aiName,\\n+ onSend,\\n+ },\\n+ ref,\\n+ ) {\\n+ const { language } = i18next;\\n+ const { getLogo } = config.global;\\n+ const { t } = useTranslation();\\n+ const scrollBottomRef = useRef(null);\\n+\\n+ // Forward scrollBottomRef to parent\\n+ useImperativeHandle(\\n+ ref,\\n+ () => ({\\n+ scrollBottomRef,\\n+ }),\\n+ [],\\n+ );\\n+\\n+ const [messageLoadState, setMessageLoadState] = React.useState(\\n+ messages.map((m) => ({\\n+ id: m.id,\\n+ loaded: false,\\n+ imagesCount: 0,\\n+ loadedImagesCount: 0,\\n+ })),\\n+ );\\n+ const messageLoadStateRef = React.useRef(messageLoadState);\\n+ const prevMessageIdsRef = React.useRef(\\n+ messages.map((m) => m?.id).join(\\\",\\\"),\\n+ );\\n+ const prevStreamingContentRef = React.useRef(streamingContent);\\n+ const prevChatIdRef = React.useRef(chatId);\\n+\\n+ const chat = useGetActiveChat()?.data;\\n+ const updateChat = useUpdateChat();\\n+ const codeRequestId = chat?.codeRequestId;\\n+ const getAutogenRun = useGetAutogenRun(codeRequestId);\\n+\\n+ // Reset scroll when switching chats\\n+ useEffect(() => {\\n+ if (chatId !== prevChatIdRef.current) {\\n+ scrollBottomRef.current?.resetScrollState();\\n+ prevChatIdRef.current = chatId;\\n+ }\\n+ }, [chatId]);\\n+\\n+ // Track streaming content updates without forcing scroll\\n+ React.useEffect(() => {\\n+ prevStreamingContentRef.current = streamingContent;\\n+ }, [streamingContent]);\\n+\\n+ const setCodeRequestFinalData = useCallback(\\n+ (data) => {\\n+ const message = {\\n+ payload: data,\\n+ sender: \\\"labeeb\\\",\\n+ sentTime: \\\"just now\\\",\\n+ direction: \\\"incoming\\\",\\n+ position: \\\"single\\\",\\n+ tool: '{\\\"toolUsed\\\":\\\"coding\\\"}',\\n+ };\\n+\\n+ updateChat.mutateAsync({\\n+ chatId,\\n+ codeRequestId: null,\\n+ isChatLoading: false,\\n+ messages: chat?.messages\\n+ ? [...chat.messages, message]\\n+ : [message],\\n+ });\\n+ },\\n+ [chatId, updateChat, chat?.messages],\\n+ );\\n+\\n+ useEffect(() => {\\n+ const data = getAutogenRun?.data?.data?.data;\\n+ if (data) {\\n+ setCodeRequestFinalData(data);\\n+ }\\n+ }, [getAutogenRun?.data?.data, setCodeRequestFinalData]);\\n+\\n+ useEffect(() => {\\n+ const newMessageIds = messages.map((m) => m?.id).join(\\\",\\\");\\n+ if (prevMessageIdsRef.current === newMessageIds) return;\\n+\\n+ prevMessageIdsRef.current = newMessageIds;\\n+ const newMessageLoadState = messages.map((m) => {\\n+ const existing = messageLoadStateRef.current.find(\\n+ (mls) => mls.id === m.id,\\n+ );\\n+ if (existing) return existing;\\n+\\n+ const messageHasImages = hasImages(m);\\n+ const imageCount = messageHasImages ? countImages(m) : 0;\\n+ return {\\n+ id: m.id,\\n+ loaded: !messageHasImages, // If no images, mark as loaded immediately\\n+ imagesCount: imageCount,\\n+ loadedImagesCount: 0,\\n+ };\\n });\\n- });\\n- };\\n \\n- const loadComplete = messageLoadState.every((m) => m.loaded);\\n+ messageLoadStateRef.current = newMessageLoadState;\\n+ setMessageLoadState(newMessageLoadState);\\n+ }, [messages]);\\n \\n- return (\\n- <>\\n- <ScrollToBottom loadComplete={loadComplete}>\\n- {messages.length === 0 && (\\n- <div className=\\\"no-message-message text-gray-400\\\">\\n- {t(\\\"Send a message to start a conversation\\\")}\\n- </div>\\n- )}\\n- {messages.map((message, index) => {\\n- const newMessage = { ...message };\\n- if (!newMessage.id) {\\n- newMessage.id = newMessage._id || index;\\n+ const rowHeight = \\\"h-12 [.docked_&]:h-10\\\";\\n+ const basis =\\n+ \\\"min-w-[3rem] basis-12 [.docked_&]:basis-10 [.docked_&]:min-w-[2.5rem]\\\";\\n+ const buttonWidthClass = \\\"w-12 [.docked_&]:w-10\\\";\\n+ const botName =\\n+ bot === \\\"code\\\"\\n+ ? config?.code?.botName\\n+ : aiName || config?.chat?.botName;\\n+\\n+ const handleMessageLoad = useCallback((messageId) => {\\n+ setMessageLoadState((prev) =>\\n+ prev.map((m) => {\\n+ if (m.id === messageId) {\\n+ const newLoadedCount = m.loadedImagesCount + 1;\\n+ return {\\n+ ...m,\\n+ loadedImagesCount: newLoadedCount,\\n+ loaded: newLoadedCount >= m.imagesCount,\\n+ };\\n }\\n- let display;\\n- if (Array.isArray(newMessage.payload)) {\\n- const arr = newMessage.payload.map((t, index2) => {\\n- try {\\n- const obj = JSON.parse(t);\\n- if (obj.type === \\\"text\\\") {\\n- return obj.text;\\n- } else if (obj.type === \\\"image_url\\\") {\\n- const src =\\n- obj?.url ||\\n- obj?.image_url?.url ||\\n- obj?.gcs;\\n- if (isVideoUrl(src)) {\\n- // Display the video\\n- return (\\n- <video\\n- onLoadedData={() => {\\n- handleMessageLoad(\\n- newMessage.id,\\n- );\\n- }}\\n- key={`video-${index}-${index2}`}\\n- src={src}\\n- className=\\\"max-h-[20%] max-w-[60%] [.docked_&]:max-w-[90%] rounded border bg-white p-1 my-2 dark:border-neutral-700 dark:bg-neutral-800 shadow-lg dark:shadow-black/30\\\"\\n- controls\\n- preload=\\\"metadata\\\"\\n- playsInline\\n- />\\n- );\\n- } else if (isAudioUrl(src)) {\\n- // Display the audio\\n- return (\\n- <audio\\n- onLoadedData={() => {\\n- handleMessageLoad(\\n- newMessage.id,\\n- );\\n- }}\\n- key={`audio-${index}-${index2}`}\\n- src={src}\\n- className=\\\"max-h-[20%] max-w-[100%] [.docked_&]:max-w-[80%] rounded-md border bg-white p-1 my-2 dark:border-neutral-700 dark:bg-neutral-800 shadow-lg dark:shadow-black/30\\\"\\n- controls\\n- />\\n- );\\n- }\\n+ return m;\\n+ }),\\n+ );\\n+ }, []);\\n \\n- if (getExtension(src) === \\\".pdf\\\") {\\n- const filename = decodeURIComponent(\\n- getFilename(src),\\n- );\\n-\\n- return (\\n- <a\\n- key={`pdf-${index}-${index2}`}\\n- className=\\\"bg-neutral-100 py-2 ps-2 pe-4 m-2 shadow-md rounded-lg border flex gap-2 items-center\\\"\\n- onLoad={() => {\\n- handleMessageLoad(\\n- newMessage.id,\\n- );\\n- }}\\n- href={src}\\n- target=\\\"_blank\\\"\\n- rel=\\\"noopener noreferrer\\\"\\n- >\\n- <AiFillFilePdf\\n- size={40}\\n- className=\\\"text-red-600 dark:text-red-400\\\"\\n- />\\n- {filename}\\n- </a>\\n- );\\n- }\\n+ const handleImageLoad = useCallback(\\n+ (messageId) => {\\n+ handleMessageLoad(messageId);\\n+ },\\n+ [handleMessageLoad],\\n+ );\\n \\n- if (getExtension(src) === \\\".txt\\\") {\\n- const filename = decodeURIComponent(\\n- getFilename(src),\\n- );\\n-\\n- return (\\n- <a\\n- key={`txt-${index}-${index2}`}\\n- className=\\\"bg-neutral-100 py-2 ps-2 pe-4 m-2 shadow-md rounded-lg border flex gap-2 items-center\\\"\\n- onLoad={() => {\\n- handleMessageLoad(\\n- newMessage.id,\\n- );\\n- }}\\n- href={src}\\n- target=\\\"_blank\\\"\\n- rel=\\\"noopener noreferrer\\\"\\n- >\\n- <AiFillFileText\\n- size={40}\\n- className=\\\"text-red-600 dark:text-red-400\\\"\\n- />\\n- {filename}\\n- </a>\\n- );\\n- }\\n+ const messageRef = useCallback(\\n+ (element, messageId) => {\\n+ if (!element) return;\\n \\n- // Display the image\\n- return (\\n- <div key={src}>\\n- <img\\n- onLoad={() => {\\n- handleMessageLoad(\\n- newMessage.id,\\n- );\\n- }}\\n- src={src}\\n- alt=\\\"uploadedimage\\\"\\n- className=\\\"max-h-[20%] max-w-[60%] [.docked_&]:max-w-[90%] rounded-md border bg-white p-1 my-2 dark:border-neutral-700 dark:bg-neutral-800 shadow-lg dark:shadow-black/30\\\"\\n- />\\n- </div>\\n- );\\n- }\\n- return null;\\n- } catch (e) {\\n- console.error(\\\"Invalid JSON:\\\", t);\\n- return t;\\n- }\\n- });\\n- display = <>{arr}</>;\\n- } else {\\n- display = newMessage.payload;\\n+ const images = element.getElementsByTagName(\\\"img\\\");\\n+ Array.from(images).forEach((img) => {\\n+ // Remove any existing listeners first\\n+ img.removeEventListener(\\\"load\\\", () =>\\n+ handleImageLoad(messageId),\\n+ );\\n+\\n+ if (!img.complete) {\\n+ img.addEventListener(\\\"load\\\", () =>\\n+ handleImageLoad(messageId),\\n+ );\\n }\\n+ });\\n+ },\\n+ [handleImageLoad],\\n+ );\\n+\\n+ const renderMessage = useCallback(\\n+ (message) => {\\n+ let avatar;\\n+ const toolData = parseToolData(message.tool);\\n \\n- // process the message and create a new\\n- // message object with the updated payload.\\n- const processedMessage = Object.assign({}, newMessage, {\\n- payload: (\\n- <React.Fragment key={`inner-${newMessage.id}`}>\\n- {newMessage.sender === \\\"labeeb\\\" ? (\\n- convertMessageToMarkdown(newMessage)\\n- ) : (\\n- <div key={`um-${index}`}>{display}</div>\\n+ if (message.sender === \\\"labeeb\\\") {\\n+ avatar = toolData?.avatarImage ? (\\n+ <img\\n+ src={toolData.avatarImage}\\n+ alt=\\\"Tool Avatar\\\"\\n+ className={classNames(\\n+ basis,\\n+ \\\"p-1\\\",\\n+ buttonWidthClass,\\n+ rowHeight,\\n+ \\\"rounded-full object-cover\\\",\\n+ )}\\n+ />\\n+ ) : bot === \\\"code\\\" ? (\\n+ <AiOutlineRobot\\n+ className={classNames(\\n+ rowHeight,\\n+ buttonWidthClass,\\n+ \\\"px-3\\\",\\n+ \\\"text-gray-400\\\",\\n+ )}\\n+ />\\n+ ) : (\\n+ <img\\n+ src={getLogo(language)}\\n+ alt=\\\"Logo\\\"\\n+ className={classNames(\\n+ basis,\\n+ \\\"p-2\\\",\\n+ buttonWidthClass,\\n+ rowHeight,\\n+ )}\\n+ />\\n+ );\\n+\\n+ return (\\n+ <div\\n+ key={message.id}\\n+ className=\\\"flex bg-sky-50 ps-1 pt-1 relative group\\\"\\n+ >\\n+ <div className=\\\"flex items-center gap-2 absolute top-3 end-3\\\">\\n+ {toolData?.toolUsed && (\\n+ <div className=\\\"tool-badge inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-sky-50 border border-sky-100 text-xs text-sky-600 font-medium w-fit\\\">\\n+ <span className=\\\"tool-icon\\\">\\n+ {\\n+ getToolMetadata(\\n+ toolData.toolUsed,\\n+ t,\\n+ ).icon\\n+ }\\n+ </span>\\n+ <span className=\\\"tool-name\\\">\\n+ {t(\\\"Used {{tool}} tool\\\", {\\n+ tool: getToolMetadata(\\n+ toolData.toolUsed,\\n+ t,\\n+ ).translatedName,\\n+ })}\\n+ </span>\\n+ </div>\\n )}\\n- </React.Fragment>\\n- ),\\n- });\\n+ <CopyButton\\n+ item={\\n+ typeof message.payload === \\\"string\\\"\\n+ ? message.payload\\n+ : message.text\\n+ }\\n+ className=\\\"copy-button opacity-0 group-hover:opacity-60 hover:opacity-100 transition-opacity\\\"\\n+ />\\n+ </div>\\n \\n+ <div className={classNames(basis)}>{avatar}</div>\\n+ <div\\n+ className={classNames(\\n+ \\\"px-1 pb-3 pt-2 [.docked_&]:px-0 [.docked_&]:py-3 w-full\\\",\\n+ )}\\n+ >\\n+ <div className=\\\"flex flex-col\\\">\\n+ <div className=\\\"font-semibold\\\">\\n+ {t(botName)}\\n+ </div>\\n+ <div\\n+ className=\\\"chat-message-bot relative break-words\\\"\\n+ ref={(el) => messageRef(el, message.id)}\\n+ >\\n+ <React.Fragment\\n+ key={`md-${message.id}`}\\n+ >\\n+ <MemoizedMarkdownMessage\\n+ message={message}\\n+ />\\n+ </React.Fragment>\\n+ </div>\\n+ </div>\\n+ </div>\\n+ </div>\\n+ );\\n+ } else {\\n+ avatar = (\\n+ <FaUserCircle\\n+ className={classNames(\\n+ rowHeight,\\n+ buttonWidthClass,\\n+ \\\"p-2\\\",\\n+ \\\"text-gray-300\\\",\\n+ )}\\n+ />\\n+ );\\n return (\\n- <div key={processedMessage.id}>\\n- {renderMessage(processedMessage)}\\n+ <div\\n+ key={message.id}\\n+ className=\\\"flex ps-1 pt-1 relative group\\\"\\n+ >\\n+ <CopyButton\\n+ item={\\n+ typeof message.payload === \\\"string\\\"\\n+ ? message.payload\\n+ : \\\"\\\"\\n+ }\\n+ className=\\\"absolute top-3 end-3 opacity-0 group-hover:opacity-60 hover:opacity-100 transition-opacity\\\"\\n+ />\\n+ <div className={classNames(basis, \\\"py-0\\\")}>\\n+ {avatar}\\n+ </div>\\n+ <div\\n+ className={classNames(\\n+ \\\"px-1 pb-3 pt-2 [.docked_&]:px-0 [.docked_&]:py-3\\\",\\n+ )}\\n+ >\\n+ <div className=\\\"font-semibold\\\">{t(\\\"You\\\")}</div>\\n+ <pre className=\\\"chat-message-user\\\">\\n+ {message.payload}\\n+ </pre>\\n+ </div>\\n </div>\\n );\\n- })}\\n- {loading &&\\n- renderMessage({\\n- id: \\\"loading\\\",\\n- sender: \\\"labeeb\\\",\\n- payload: (\\n- <div className=\\\"flex gap-4\\\">\\n- <div className=\\\"mt-1 ms-1 mb-1 h-4\\\">\\n- <Loader />\\n- </div>\\n- {codeRequestId && (\\n- <div className=\\\"border pt-5 pb-3 px-7 rounded-md bg-white animate-fade-in\\\">\\n- <ProgressUpdate\\n- requestId={codeRequestId}\\n- setFinalData={\\n- setCodeRequestFinalData\\n- }\\n- initialText={\\\"🤖 Agent coding...\\\"}\\n- codeAgent={true}\\n- />\\n+ }\\n+ },\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n+ [\\n+ basis,\\n+ bot,\\n+ buttonWidthClass,\\n+ getLogo,\\n+ language,\\n+ messageRef,\\n+ rowHeight,\\n+ t,\\n+ ],\\n+ );\\n+\\n+ const loadComplete = messageLoadState.every((m) => m.loaded);\\n+\\n+ return (\\n+ <ScrollToBottom ref={scrollBottomRef} loadComplete={loadComplete}>\\n+ <div className=\\\"flex flex-col\\\">\\n+ {messages.length === 0 && !isStreaming && (\\n+ <div className=\\\"no-message-message text-gray-400\\\">\\n+ {t(\\\"Send a message to start a conversation\\\")}\\n+ </div>\\n+ )}\\n+ <div className=\\\"flex-1 overflow-hidden\\\">\\n+ <MessageListContent\\n+ messages={messages}\\n+ renderMessage={renderMessage}\\n+ handleMessageLoad={handleMessageLoad}\\n+ isVideoUrl={isVideoUrl}\\n+ isAudioUrl={isAudioUrl}\\n+ getExtension={getExtension}\\n+ getFilename={getFilename}\\n+ getYoutubeEmbedUrl={getYoutubeEmbedUrl}\\n+ />\\n+ {isStreaming && (\\n+ <StreamingMessage\\n+ content={streamingContent}\\n+ bot={bot}\\n+ aiName={aiName}\\n+ />\\n+ )}\\n+ {loading &&\\n+ !isStreaming &&\\n+ !codeRequestId &&\\n+ renderMessage({\\n+ id: \\\"loading\\\",\\n+ sender: \\\"labeeb\\\",\\n+ payload: (\\n+ <div className=\\\"flex gap-4\\\">\\n+ <div className=\\\"mt-1 ms-1 mb-1 h-4\\\">\\n+ <Loader />\\n+ </div>\\n </div>\\n- )}\\n+ ),\\n+ })}\\n+ {loading && !isStreaming && codeRequestId && (\\n+ <div className=\\\"border pt-5 pb-3 px-7 rounded-md bg-white animate-fade-in\\\">\\n+ <ProgressUpdate\\n+ requestId={codeRequestId}\\n+ setFinalData={setCodeRequestFinalData}\\n+ initialText={\\\"🤖 Agent coding...\\\"}\\n+ codeAgent={true}\\n+ />\\n </div>\\n- ),\\n- })}\\n+ )}\\n+ </div>\\n+ </div>\\n </ScrollToBottom>\\n- </>\\n- );\\n-}\\n+ );\\n+ }),\\n+);\\n \\n export default MessageList;\\ndiff --git a/src/components/chat/MyFilePond.js b/src/components/chat/MyFilePond.js\\nindex 66cd148..b68fc9c 100644\\n--- a/src/components/chat/MyFilePond.js\\n+++ b/src/components/chat/MyFilePond.js\\n@@ -17,7 +17,15 @@ import FilePondPluginImagePreview from \\\"filepond-plugin-image-preview\\\";\\n import \\\"filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css\\\";\\n import { useEffect, useRef, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n-import { hashMediaFile } from \\\"../../utils/mediaUtils\\\";\\n+import {\\n+ hashMediaFile,\\n+ DOC_MIME_TYPES,\\n+ ACCEPTED_FILE_TYPES,\\n+ isMediaUrl,\\n+ getFilename,\\n+ getVideoDuration,\\n+} from \\\"../../utils/mediaUtils\\\";\\n+import { isYoutubeUrl } from \\\"../../utils/urlUtils\\\";\\n \\n // Global upload speed tracking\\n let lastBytesPerMs = null; // bytes per millisecond from last successful upload including cloud processing\\n@@ -105,174 +113,9 @@ function RemoteUrlInputUI({\\n );\\n }\\n \\n-const DOC_EXTENSIONS = [\\n- \\\".json\\\",\\n- \\\".csv\\\",\\n- \\\".md\\\",\\n- \\\".xml\\\",\\n- \\\".js\\\",\\n- \\\".html\\\",\\n- \\\".css\\\",\\n- \\\".docx\\\",\\n- \\\".xlsx\\\",\\n- \\\".xls\\\",\\n- \\\".doc\\\",\\n-];\\n-\\n-const IMAGE_EXTENSIONS = [\\n- \\\".jpg\\\",\\n- \\\".jpeg\\\",\\n- \\\".png\\\",\\n- \\\".webp\\\",\\n- \\\".heic\\\",\\n- \\\".heif\\\",\\n- \\\".pdf\\\",\\n- \\\".txt\\\",\\n-];\\n-\\n-const VIDEO_EXTENSIONS = [\\n- \\\".mp4\\\",\\n- \\\".mpeg\\\",\\n- \\\".mov\\\",\\n- \\\".avi\\\",\\n- \\\".flv\\\",\\n- \\\".mpg\\\",\\n- \\\".mov\\\",\\n- \\\".webm\\\",\\n- \\\".wmv\\\",\\n- \\\".3gp\\\",\\n-];\\n-\\n-const AUDIO_EXTENSIONS = [\\\".wav\\\", \\\".mp3\\\", \\\".m4a\\\", \\\".aac\\\", \\\".ogg\\\", \\\".flac\\\"];\\n-\\n-function isDocumentUrl(url) {\\n- const urlExt = getExtension(url);\\n- return DOC_EXTENSIONS.includes(urlExt);\\n-}\\n-\\n-// Extracts the filename from a URL\\n-export function getFilename(url) {\\n- try {\\n- // Create a URL object to handle parsing\\n- const urlObject = new URL(url);\\n-\\n- // Get the pathname and remove leading/trailing slashes\\n- const path = urlObject.pathname.replace(/^\\\\/|\\\\/$/g, \\\"\\\");\\n-\\n- // Get the last part of the path (filename)\\n- const fullFilename = path.split(\\\"/\\\").pop() || \\\"\\\";\\n-\\n- // Decode the filename to handle URL encoding\\n- const decodedFilename = decodeURIComponent(fullFilename);\\n-\\n- // Split by underscore and remove the first part if it exists\\n- const parts = decodedFilename.split(\\\"_\\\");\\n- const relevantParts = parts.length > 1 ? parts.slice(1) : parts;\\n-\\n- // Join the parts back together\\n- return relevantParts.join(\\\"_\\\");\\n- } catch (error) {\\n- console.error(\\\"Error parsing URL:\\\", error);\\n- return \\\"\\\";\\n- }\\n-}\\n-\\n-export function getExtension(url) {\\n- try {\\n- const parsedUrl = new URL(url);\\n- const pathname = parsedUrl.pathname;\\n- return \\\".\\\" + pathname.split(\\\".\\\").pop().toLowerCase();\\n- } catch (error) {\\n- return \\\".\\\" + url.split(\\\".\\\").pop().split(/[?#]/)[0].toLowerCase();\\n- }\\n-}\\n-\\n-function isImageUrl(url) {\\n- const urlExt = getExtension(url);\\n- const mimeType = mime.contentType(urlExt);\\n- return (\\n- IMAGE_EXTENSIONS.includes(urlExt) &&\\n- (mimeType.startsWith(\\\"image/\\\") ||\\n- mimeType === \\\"application/pdf\\\" ||\\n- mimeType.startsWith(\\\"text/plain\\\"))\\n- );\\n-}\\n-\\n-function isVideoUrl(url) {\\n- const urlExt = getExtension(url);\\n- const mimeType = mime.contentType(urlExt);\\n- return VIDEO_EXTENSIONS.includes(urlExt) && mimeType.startsWith(\\\"video/\\\");\\n-}\\n-\\n-function isAudioUrl(url) {\\n- const urlExt = getExtension(url);\\n- const mimeType = mime.contentType(urlExt);\\n- return AUDIO_EXTENSIONS.includes(urlExt) && mimeType.startsWith(\\\"audio/\\\");\\n-}\\n-\\n-function isMediaUrl(url) {\\n- return isImageUrl(url) || isVideoUrl(url) || isAudioUrl(url);\\n-}\\n-\\n-const DOC_MIME_TYPES = DOC_EXTENSIONS.map((ext) => mime.lookup(ext));\\n-const MEDIA_MIME_TYPES = [\\n- // Images\\n- \\\"image/png\\\",\\n- \\\"image/jpeg\\\",\\n- \\\"image/webp\\\",\\n- \\\"image/heic\\\",\\n- \\\"image/heif\\\",\\n- // Videos\\n- \\\"video/mp4\\\",\\n- \\\"video/mpeg\\\",\\n- \\\"video/mov\\\",\\n- \\\"video/quicktime\\\",\\n- \\\"video/avi\\\",\\n- \\\"video/x-flv\\\",\\n- \\\"video/mpg\\\",\\n- \\\"video/webm\\\",\\n- \\\"video/wmv\\\",\\n- \\\"video/3gpp\\\",\\n- \\\"video/m4v\\\",\\n- // Audio\\n- \\\"audio/wav\\\",\\n- \\\"audio/mpeg\\\",\\n- \\\"audio/aac\\\",\\n- \\\"audio/ogg\\\",\\n- \\\"audio/flac\\\",\\n- \\\"audio/m4a\\\",\\n- \\\"audio/mp3\\\",\\n- \\\"audio/mp4\\\",\\n- \\\"audio/x-m4a\\\", // Common browser MIME type for .m4a files\\n- // PDF\\n- \\\"application/pdf\\\",\\n- // Text\\n- \\\"text/plain\\\",\\n-];\\n-\\n-const ACCEPTED_FILE_TYPES = [...DOC_MIME_TYPES, ...MEDIA_MIME_TYPES];\\n-\\n-// Add this helper function to check video duration\\n-function getVideoDuration(file) {\\n- return new Promise((resolve, reject) => {\\n- const video = document.createElement(\\\"video\\\");\\n- video.preload = \\\"metadata\\\";\\n-\\n- video.onloadedmetadata = function () {\\n- window.URL.revokeObjectURL(video.src);\\n- resolve(video.duration);\\n- };\\n-\\n- video.onerror = function () {\\n- reject(\\\"Error loading video file\\\");\\n- };\\n-\\n- video.src = URL.createObjectURL(file);\\n- });\\n-}\\n-\\n // Our app\\n function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n+ const pondRef = useRef(null);\\n const serverUrl = \\\"/media-helper?useGoogle=true\\\";\\n const [inputUrl, setInputUrl] = useState(\\\"\\\");\\n const [showInputUI, setShowInputUI] = useState(false);\\n@@ -288,11 +131,51 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n try {\\n new URL(inputUrl);\\n } catch (err) {\\n- // Invalid URL format\\n alert(t(\\\"Please enter a valid URL\\\"));\\n return;\\n }\\n \\n+ // If it's a YouTube URL, simulate an instant upload through FilePond's API\\n+ if (isYoutubeUrl(inputUrl)) {\\n+ const youtubeResponse = {\\n+ url: inputUrl,\\n+ gcs: inputUrl,\\n+ type: \\\"video/youtube\\\", // custom type used internally\\n+ filename: getFilename(inputUrl),\\n+ payload: JSON.stringify([\\n+ JSON.stringify({\\n+ type: \\\"image_url\\\",\\n+ url: inputUrl,\\n+ gcs: inputUrl,\\n+ }),\\n+ ]),\\n+ };\\n+\\n+ // Pass the response to your existing chat logic\\n+ addUrl(youtubeResponse);\\n+\\n+ // Create a pre-loaded file object\\n+ setFiles((prevFiles) => [\\n+ ...prevFiles,\\n+ {\\n+ source: youtubeResponse,\\n+ options: {\\n+ type: \\\"limbo\\\",\\n+ file: {\\n+ name: getFilename(inputUrl),\\n+ type: \\\"video/youtube\\\",\\n+ size: 0,\\n+ },\\n+ },\\n+ },\\n+ ]);\\n+\\n+ // Clear the URL input\\n+ setInputUrl(\\\"\\\");\\n+ return;\\n+ }\\n+\\n+ // For non-YouTube URLs, continue with the existing logic\\n setIsUploadingMedia(true);\\n setFiles([...files, { source: inputUrl }]);\\n setInputUrl(\\\"\\\");\\n@@ -339,6 +222,7 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n <div className=\\\"flex\\\">\\n <div className=\\\"flex-grow w-full\\\">\\n <FilePond\\n+ ref={pondRef}\\n files={files}\\n onupdatefiles={setFiles}\\n allowFileTypeValidation={true}\\n@@ -356,16 +240,27 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n server={{\\n url: serverUrl,\\n fetch: async (\\n- url,\\n+ fileUrl,\\n load,\\n error,\\n progress,\\n abort,\\n headers,\\n ) => {\\n+ // For YouTube URLs, immediately load without fetching\\n+ if (isYoutubeUrl(fileUrl)) {\\n+ const response = {\\n+ url: fileUrl,\\n+ gcs: fileUrl,\\n+ type: \\\"video/youtube\\\",\\n+ filename: getFilename(fileUrl),\\n+ };\\n+ load(response);\\n+ return;\\n+ }\\n try {\\n const response = await axios.get(\\n- `${serverUrl}&fetch=${url}`,\\n+ `${serverUrl}&fetch=${fileUrl}`,\\n );\\n if (response.data && response.data.url) {\\n const { url } = response.data;\\n@@ -377,8 +272,6 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n load(\\n new Blob([url], {\\n type,\\n- filename,\\n- url,\\n }),\\n );\\n addUrl(response.data);\\n@@ -387,11 +280,7 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n }\\n } catch (err) {\\n console.error(err);\\n- error({\\n- body:\\n- err.response?.data || \\\"Invalid URL\\\",\\n- type: \\\"error\\\",\\n- });\\n+ error(\\\"Could not load file\\\");\\n setIsUploadingMedia(false);\\n }\\n },\\n@@ -404,6 +293,25 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n progress,\\n abort,\\n ) => {\\n+ // Handle YouTube URLs differently\\n+ if (\\n+ file.type === \\\"video/youtube\\\" ||\\n+ (metadata &&\\n+ metadata.type === \\\"video/youtube\\\")\\n+ ) {\\n+ const response = {\\n+ url: file.name || file,\\n+ gcs: file.name || file,\\n+ type: \\\"video/youtube\\\",\\n+ filename: getFilename(\\n+ file.name || file,\\n+ ),\\n+ };\\n+ progress(true, 100, 100);\\n+ load(response);\\n+ return;\\n+ }\\n+\\n setProcessingLabel(t(\\\"Checking file...\\\"));\\n setIsUploadingMedia(true);\\n \\n@@ -701,5 +609,3 @@ function MyFilePond({ addUrl, files, setFiles, setIsUploadingMedia }) {\\n }\\n \\n export default MyFilePond;\\n-\\n-export { isAudioUrl, isDocumentUrl, isImageUrl, isMediaUrl, isVideoUrl };\\ndiff --git a/src/components/chat/SavedChats.js b/src/components/chat/SavedChats.js\\nindex ab5d51c..f3829c8 100644\\n--- a/src/components/chat/SavedChats.js\\n+++ b/src/components/chat/SavedChats.js\\n@@ -1,8 +1,14 @@\\n+import {\\n+ DropdownMenu,\\n+ DropdownMenuContent,\\n+ DropdownMenuItem,\\n+ DropdownMenuTrigger,\\n+} from \\\"@/components/ui/dropdown-menu\\\";\\n import { PlusIcon } from \\\"@heroicons/react/24/outline\\\";\\n import dayjs from \\\"dayjs\\\";\\n import relativeTime from \\\"dayjs/plugin/relativeTime\\\";\\n import i18next from \\\"i18next\\\";\\n-import { EditIcon, TrashIcon, XIcon } from \\\"lucide-react\\\";\\n+import { EditIcon, MoreVertical, TrashIcon, XIcon } from \\\"lucide-react\\\";\\n import { useRouter } from \\\"next/navigation\\\";\\n import { useEffect, useMemo, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n@@ -194,7 +200,7 @@ function SavedChats({ displayState }) {\\n className={classNames(\\n editingId === chat._id\\n ? \\\"flex\\\"\\n- : \\\"hidden group-hover:flex\\\",\\n+ : \\\"hidden sm:group-hover:flex\\\",\\n \\\"items-center gap-1 -mt-5 -me-2\\\",\\n )}\\n >\\n@@ -234,6 +240,37 @@ function SavedChats({ displayState }) {\\n <TrashIcon className=\\\"h-3 w-3 flex-shrink-0\\\" />\\n </button>\\n </div>\\n+ {editingId !== chat._id && (\\n+ <div className=\\\"block sm:hidden -me-2 -mt-2\\\">\\n+ <DropdownMenu>\\n+ <DropdownMenuTrigger className=\\\"\\\">\\n+ <MoreVertical className=\\\"h-3 w-3 text-gray-400\\\" />\\n+ </DropdownMenuTrigger>\\n+ <DropdownMenuContent>\\n+ {/* add Edit and taxonomy options here */}\\n+ <DropdownMenuItem\\n+ className=\\\"text-sm\\\"\\n+ onClick={() => {\\n+ setEditingId(chat._id);\\n+ setEditedName(\\n+ chat.title,\\n+ );\\n+ }}\\n+ >\\n+ {t(\\\"Edit title\\\")}\\n+ </DropdownMenuItem>\\n+ <DropdownMenuItem\\n+ className=\\\"text-sm\\\"\\n+ onClick={() => {\\n+ handleDelete(chat._id);\\n+ }}\\n+ >\\n+ {t(\\\"Delete chat\\\")}\\n+ </DropdownMenuItem>\\n+ </DropdownMenuContent>\\n+ </DropdownMenu>\\n+ </div>\\n+ )}\\n </div>\\n <div className=\\\"flex justify-between items-center pb-2 overflow-hidden text-start w-full\\\">\\n <ul className=\\\"w-full\\\">\\ndiff --git a/src/components/chat/ScrollToBottom.js b/src/components/chat/ScrollToBottom.js\\nindex 23c805e..cb68cd2 100644\\n--- a/src/components/chat/ScrollToBottom.js\\n+++ b/src/components/chat/ScrollToBottom.js\\n@@ -1,27 +1,126 @@\\n-import React, { useEffect, useRef } from \\\"react\\\";\\n+import React, {\\n+ useEffect,\\n+ useRef,\\n+ useCallback,\\n+ useImperativeHandle,\\n+ forwardRef,\\n+} from \\\"react\\\";\\n \\n-const ScrollToBottom = ({ children, loadComplete }) => {\\n+const ScrollToBottom = forwardRef(({ children, loadComplete }, ref) => {\\n const containerRef = useRef(null);\\n+ const userHasScrolledUp = useRef(false);\\n+ const lastScrollTop = useRef(0);\\n+ const scrollAttempts = useRef(0);\\n+ const maxScrollAttempts = 3; // Maximum number of scroll attempts\\n \\n+ const scrollToBottom = useCallback(() => {\\n+ if (!containerRef.current) return;\\n+\\n+ const { scrollHeight, clientHeight } = containerRef.current;\\n+ containerRef.current.scrollTo({\\n+ top: scrollHeight - clientHeight,\\n+ behavior: \\\"auto\\\",\\n+ });\\n+ }, []);\\n+\\n+ // Check if we're actually at the bottom and retry if not\\n+ const verifyScrollPosition = useCallback(() => {\\n+ if (!containerRef.current || userHasScrolledUp.current) return;\\n+\\n+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;\\n+ const isAtBottom =\\n+ Math.abs(scrollTop + clientHeight - scrollHeight) < 10;\\n+\\n+ if (!isAtBottom && scrollAttempts.current < maxScrollAttempts) {\\n+ // If not at bottom, try scrolling again with a slight delay\\n+ scrollAttempts.current += 1;\\n+ setTimeout(() => {\\n+ scrollToBottom();\\n+ // Check again after scrolling\\n+ setTimeout(verifyScrollPosition, 100);\\n+ }, 50 * scrollAttempts.current); // Increasing delay with each attempt\\n+ } else {\\n+ // Reset attempts counter after we're done\\n+ scrollAttempts.current = 0;\\n+ }\\n+ }, [scrollToBottom]);\\n+\\n+ // Enhanced scroll to bottom that verifies position\\n+ const enhancedScrollToBottom = useCallback(() => {\\n+ scrollAttempts.current = 0; // Reset attempts counter\\n+ scrollToBottom();\\n+ // Verify scroll position after initial scroll\\n+ setTimeout(verifyScrollPosition, 100);\\n+ }, [scrollToBottom, verifyScrollPosition]);\\n+\\n+ // Reset scroll state and scroll to bottom\\n+ const resetScrollState = useCallback(() => {\\n+ userHasScrolledUp.current = false;\\n+ enhancedScrollToBottom();\\n+ }, [enhancedScrollToBottom]);\\n+\\n+ // Expose reset function to parent\\n+ useImperativeHandle(\\n+ ref,\\n+ () => ({\\n+ resetScrollState,\\n+ }),\\n+ [resetScrollState],\\n+ );\\n+\\n+ // Scroll to bottom on new messages if user hasn't scrolled up\\n useEffect(() => {\\n- // Scrolls to the bottom of the chat container\\n- const scroll = () => {\\n- const scrollHeight = containerRef.current.scrollHeight;\\n- const height = containerRef.current.clientHeight;\\n- const maxScrollTop = scrollHeight - height;\\n- containerRef.current.scrollTop =\\n- maxScrollTop > 0 ? maxScrollTop : 0;\\n- };\\n-\\n- scroll();\\n- // Dependency array ensures effect runs when 'children' changes or when loading is complete\\n- }, [children, loadComplete]);\\n+ if (!userHasScrolledUp.current) {\\n+ enhancedScrollToBottom();\\n+ }\\n+ }, [children, enhancedScrollToBottom]);\\n+\\n+ // Additional effect to ensure we scroll after all content is loaded\\n+ useEffect(() => {\\n+ if (loadComplete && !userHasScrolledUp.current) {\\n+ enhancedScrollToBottom();\\n+ }\\n+ }, [loadComplete, enhancedScrollToBottom]);\\n+\\n+ // Final check after a delay to catch any late-rendering content\\n+ useEffect(() => {\\n+ if (loadComplete && !userHasScrolledUp.current) {\\n+ const timeoutId = setTimeout(() => {\\n+ verifyScrollPosition();\\n+ }, 300); // Longer delay to catch late DOM updates\\n+\\n+ return () => clearTimeout(timeoutId);\\n+ }\\n+ }, [loadComplete, verifyScrollPosition]);\\n \\n return (\\n- <div ref={containerRef} style={{ overflowY: \\\"auto\\\", height: \\\"100%\\\" }}>\\n+ <div\\n+ ref={containerRef}\\n+ className=\\\"overflow-y-auto h-full min-h-0 flex-1\\\"\\n+ onScroll={() => {\\n+ if (!containerRef.current) return;\\n+\\n+ const { scrollTop, scrollHeight, clientHeight } =\\n+ containerRef.current;\\n+ const isScrollingUp = scrollTop < lastScrollTop.current;\\n+ lastScrollTop.current = scrollTop;\\n+\\n+ // If scrolling up and not already marked as scrolled up\\n+ if (isScrollingUp && !userHasScrolledUp.current) {\\n+ userHasScrolledUp.current = true;\\n+ }\\n+\\n+ // If we reach bottom, re-enable auto-scroll\\n+ const isAtBottom =\\n+ Math.abs(scrollTop + clientHeight - scrollHeight) < 10;\\n+ if (isAtBottom) {\\n+ userHasScrolledUp.current = false;\\n+ }\\n+ }}\\n+ >\\n {children}\\n </div>\\n );\\n-};\\n+});\\n \\n export default ScrollToBottom;\\ndiff --git a/src/components/chat/StreamingMessage.js b/src/components/chat/StreamingMessage.js\\nnew file mode 100644\\nindex 0000000..cf6c675\\n--- /dev/null\\n+++ b/src/components/chat/StreamingMessage.js\\n@@ -0,0 +1,235 @@\\n+import React, {\\n+ useEffect,\\n+ useRef,\\n+ useState,\\n+ useCallback,\\n+ useMemo,\\n+} from \\\"react\\\";\\n+import { convertMessageToMarkdown } from \\\"./ChatMessage\\\";\\n+import { AiOutlineRobot } from \\\"react-icons/ai\\\";\\n+import classNames from \\\"../../../app/utils/class-names\\\";\\n+import config from \\\"../../../config\\\";\\n+import { useTranslation } from \\\"react-i18next\\\";\\n+import i18next from \\\"i18next\\\";\\n+import Loader from \\\"../../../app/components/loader\\\";\\n+\\n+// Memoize the content component to prevent re-renders when only the loader position changes\\n+const StreamingContent = React.memo(function StreamingContent({\\n+ content,\\n+ onContentUpdate,\\n+}) {\\n+ const contentRef = useRef(null);\\n+ const markdownContent = useMemo(() => {\\n+ return convertMessageToMarkdown({\\n+ payload: content,\\n+ sender: \\\"labeeb\\\",\\n+ });\\n+ }, [content]);\\n+\\n+ useEffect(() => {\\n+ if (contentRef.current) {\\n+ // Ensure we call onContentUpdate after the content has been rendered\\n+ requestAnimationFrame(() => {\\n+ onContentUpdate(contentRef.current);\\n+ });\\n+ }\\n+ }, [content, onContentUpdate]);\\n+\\n+ return (\\n+ <div\\n+ ref={contentRef}\\n+ className=\\\"chat-message-bot relative break-words text-gray-800\\\"\\n+ >\\n+ {markdownContent}\\n+ </div>\\n+ );\\n+});\\n+\\n+const StreamingMessage = React.memo(function StreamingMessage({\\n+ content,\\n+ bot,\\n+ aiName,\\n+}) {\\n+ const contentNodeRef = useRef(null);\\n+ const [loaderPosition, setLoaderPosition] = useState({ x: 0, y: 0 });\\n+ const [showLoader, setShowLoader] = useState(false);\\n+ const lastUpdateRef = useRef(Date.now());\\n+ const loaderTimeoutRef = useRef(null);\\n+ const { t } = useTranslation();\\n+ const { language } = i18next;\\n+ const { getLogo } = config.global;\\n+\\n+ const calculateLoaderPosition = useCallback((contentNode) => {\\n+ if (!contentNode) return;\\n+\\n+ // Get all text nodes, including those in nested elements\\n+ const walker = document.createTreeWalker(\\n+ contentNode,\\n+ NodeFilter.SHOW_TEXT,\\n+ {\\n+ acceptNode: (node) => {\\n+ if (!node.textContent.trim()) {\\n+ return NodeFilter.FILTER_SKIP;\\n+ }\\n+ return NodeFilter.FILTER_ACCEPT;\\n+ },\\n+ },\\n+ );\\n+\\n+ let lastTextNode = null;\\n+ let lastNodeRect = null;\\n+\\n+ while (walker.nextNode()) {\\n+ const node = walker.currentNode;\\n+ const range = document.createRange();\\n+ range.selectNodeContents(node);\\n+ const rects = range.getClientRects();\\n+\\n+ if (rects.length > 0) {\\n+ lastTextNode = node;\\n+ lastNodeRect = rects[rects.length - 1];\\n+ }\\n+ }\\n+\\n+ if (lastTextNode && lastNodeRect) {\\n+ const range = document.createRange();\\n+ range.setStart(lastTextNode, lastTextNode.textContent.length);\\n+ range.setEnd(lastTextNode, lastTextNode.textContent.length);\\n+\\n+ const rect = range.getBoundingClientRect();\\n+ const contentRect = contentNode.getBoundingClientRect();\\n+ const textContainer = lastTextNode.parentElement;\\n+ const computedStyle = window.getComputedStyle(textContainer);\\n+ const fontSize = parseFloat(computedStyle.fontSize);\\n+ const textMiddle = rect.top + rect.height / 2;\\n+ const loaderHeight = 16;\\n+\\n+ setLoaderPosition({\\n+ x:\\n+ rect.right -\\n+ contentRect.left +\\n+ Math.min(fontSize * 0.25, 4) +\\n+ 5,\\n+ y: textMiddle - contentRect.top - loaderHeight / 2 - 3,\\n+ });\\n+ }\\n+ }, []);\\n+\\n+ const handleContentUpdate = useCallback(\\n+ (contentNode) => {\\n+ if (!contentNode) return;\\n+\\n+ contentNodeRef.current = contentNode;\\n+ const now = Date.now();\\n+\\n+ // Clear any existing loader timeout\\n+ if (loaderTimeoutRef.current) {\\n+ clearTimeout(loaderTimeoutRef.current);\\n+ loaderTimeoutRef.current = null;\\n+ }\\n+\\n+ // If we're actively streaming, hide the loader and schedule showing it\\n+ if (now - lastUpdateRef.current < 200) {\\n+ setShowLoader(false);\\n+ }\\n+\\n+ // Always schedule the loader to appear after 200ms\\n+ loaderTimeoutRef.current = setTimeout(() => {\\n+ if (contentNodeRef.current) {\\n+ setShowLoader(true);\\n+ calculateLoaderPosition(contentNodeRef.current);\\n+ }\\n+ }, 200);\\n+\\n+ lastUpdateRef.current = now;\\n+ },\\n+ [calculateLoaderPosition],\\n+ );\\n+\\n+ // Update loader position when content changes\\n+ useEffect(() => {\\n+ if (showLoader && contentNodeRef.current) {\\n+ calculateLoaderPosition(contentNodeRef.current);\\n+ }\\n+ }, [content, showLoader, calculateLoaderPosition]);\\n+\\n+ // Cleanup timeout\\n+ useEffect(() => {\\n+ return () => {\\n+ if (loaderTimeoutRef.current) {\\n+ clearTimeout(loaderTimeoutRef.current);\\n+ }\\n+ };\\n+ }, []);\\n+\\n+ let rowHeight = \\\"h-12 [.docked_&]:h-10\\\";\\n+ let basis =\\n+ \\\"min-w-[3rem] basis-12 [.docked_&]:basis-10 [.docked_&]:min-w-[2.5rem]\\\";\\n+ let buttonWidthClass = \\\"w-12 [.docked_&]:w-10\\\";\\n+ const botName =\\n+ bot === \\\"code\\\"\\n+ ? config?.code?.botName\\n+ : aiName || config?.chat?.botName;\\n+\\n+ const avatar = useMemo(() => {\\n+ return bot === \\\"code\\\" ? (\\n+ <AiOutlineRobot\\n+ className={classNames(\\n+ rowHeight,\\n+ buttonWidthClass,\\n+ \\\"px-3\\\",\\n+ \\\"text-gray-400\\\",\\n+ )}\\n+ />\\n+ ) : (\\n+ <img\\n+ src={getLogo(language)}\\n+ alt=\\\"Logo\\\"\\n+ className={classNames(\\n+ basis,\\n+ \\\"p-2\\\",\\n+ buttonWidthClass,\\n+ rowHeight,\\n+ )}\\n+ />\\n+ );\\n+ }, [bot, getLogo, language, basis, buttonWidthClass, rowHeight]);\\n+\\n+ return (\\n+ <div className=\\\"flex bg-sky-50 ps-1 pt-1 relative group\\\">\\n+ <div className={classNames(basis)}>{avatar}</div>\\n+ <div\\n+ className={classNames(\\n+ \\\"px-1 pb-3 pt-2 [.docked_&]:px-0 [.docked_&]:py-3 w-full\\\",\\n+ )}\\n+ >\\n+ <div className=\\\"flex flex-col\\\">\\n+ <div className=\\\"font-semibold text-gray-900\\\">\\n+ {t(botName)}\\n+ </div>\\n+ <div className=\\\"relative\\\">\\n+ <StreamingContent\\n+ content={content}\\n+ onContentUpdate={handleContentUpdate}\\n+ />\\n+ {showLoader && (\\n+ <div className=\\\"pointer-events-none absolute top-0 left-0 w-full h-full\\\">\\n+ <div\\n+ className=\\\"absolute transition-transform duration-100 ease-out\\\"\\n+ style={{\\n+ transform: `translate(${loaderPosition.x}px, ${loaderPosition.y}px)`,\\n+ }}\\n+ >\\n+ <Loader size=\\\"small\\\" delay={0} />\\n+ </div>\\n+ </div>\\n+ )}\\n+ </div>\\n+ </div>\\n+ </div>\\n+ </div>\\n+ );\\n+});\\n+\\n+StreamingMessage.displayName = \\\"StreamingMessage\\\";\\n+export default StreamingMessage;\\ndiff --git a/src/components/code/CodeBlock.js b/src/components/code/CodeBlock.js\\nindex 767bd9e..549e891 100644\\n--- a/src/components/code/CodeBlock.js\\n+++ b/src/components/code/CodeBlock.js\\n@@ -4,7 +4,7 @@ import CopyButton from \\\"../CopyButton\\\";\\n \\n const CodeBlock = ({ code, language }) => {\\n let highlightedCode = \\\"\\\";\\n- const trimmedCode = code.trim();\\n+ const trimmedCode = code?.trim() || \\\"\\\";\\n \\n if (language && HighlightJS.getLanguage(language)) {\\n highlightedCode = HighlightJS.highlight(trimmedCode, {\\ndiff --git a/src/components/editor/ProgressUpdate.js b/src/components/editor/ProgressUpdate.js\\nindex 983ee84..86a104b 100644\\n--- a/src/components/editor/ProgressUpdate.js\\n+++ b/src/components/editor/ProgressUpdate.js\\n@@ -43,6 +43,13 @@ const ProgressUpdate = ({\\n \\n const curInfo = data?.requestProgress?.info;\\n \\n+ // Check for error in the requestProgress data\\n+ if (data?.requestProgress?.error) {\\n+ // Handle the error by setting an error message in the UI\\n+ setInfo(`Error: ${data.requestProgress.error}`);\\n+ return;\\n+ }\\n+\\n if (result) {\\n let finalData = result;\\n try {\\ndiff --git a/src/components/images/ChatImage.js b/src/components/images/ChatImage.js\\nnew file mode 100644\\nindex 0000000..0111646\\n--- /dev/null\\n+++ b/src/components/images/ChatImage.js\\n@@ -0,0 +1,111 @@\\n+\\\"use client\\\";\\n+\\n+import React, { useEffect, useRef } from \\\"react\\\";\\n+import {\\n+ getStableImageId,\\n+ tempToPermanentUrlMap,\\n+} from \\\"../../utils/imageUtils.mjs\\\";\\n+\\n+const ChatImage = React.memo(\\n+ function ChatImage({\\n+ node,\\n+ src,\\n+ alt = \\\"\\\",\\n+ className = \\\"max-h-[20%] max-w-[60%] [.docked_&]:max-w-[90%] rounded my-2 shadow-lg dark:shadow-black/30\\\",\\n+ style = {},\\n+ onLoad,\\n+ ...props\\n+ }) {\\n+ // Check if we have a permanent URL for this temporary URL\\n+ const permanentUrl = tempToPermanentUrlMap.get(src);\\n+ const bestSrc = permanentUrl || src;\\n+\\n+ // Get a stable ID that persists even when the URL changes from temp to permanent\\n+ const stableId = getStableImageId(src, node);\\n+\\n+ // Track current src and use a loading ref to prevent flashing\\n+ const [currentSrc, setCurrentSrc] = React.useState(bestSrc);\\n+ const isLoadingNewSrc = useRef(false);\\n+ const previousSrcRef = useRef(bestSrc);\\n+\\n+ // Handle URL changes by preloading the new image\\n+ useEffect(() => {\\n+ // If the best source URL has changed\\n+ if (bestSrc !== previousSrcRef.current) {\\n+ // If we already have the image preloaded (from processImageUrls)\\n+ // we can switch immediately\\n+ if (tempToPermanentUrlMap.has(previousSrcRef.current)) {\\n+ setCurrentSrc(bestSrc);\\n+ } else {\\n+ // Otherwise, preload the new image before switching\\n+ isLoadingNewSrc.current = true; // Track that we're loading a new image\\n+\\n+ const img = new Image();\\n+ img.onload = () => {\\n+ // Only switch once the new image is loaded\\n+ setCurrentSrc(bestSrc);\\n+ isLoadingNewSrc.current = false;\\n+ };\\n+ img.onerror = () => {\\n+ // If there's an error, still swap to avoid getting stuck\\n+ setCurrentSrc(bestSrc);\\n+ isLoadingNewSrc.current = false;\\n+ };\\n+ img.src = bestSrc;\\n+ }\\n+ }\\n+\\n+ // Update the ref for the next comparison\\n+ previousSrcRef.current = bestSrc;\\n+ }, [bestSrc]);\\n+\\n+ // Also handle direct src prop changes (fallback)\\n+ useEffect(() => {\\n+ if (src !== previousSrcRef.current && !permanentUrl) {\\n+ // For direct src changes, also preload\\n+ isLoadingNewSrc.current = true;\\n+\\n+ const img = new Image();\\n+ img.onload = () => {\\n+ setCurrentSrc(src);\\n+ isLoadingNewSrc.current = false;\\n+ };\\n+ img.onerror = () => {\\n+ setCurrentSrc(src);\\n+ isLoadingNewSrc.current = false;\\n+ };\\n+ img.src = src;\\n+\\n+ previousSrcRef.current = src;\\n+ }\\n+ }, [src, permanentUrl]);\\n+\\n+ return (\\n+ <img\\n+ key={stableId}\\n+ src={currentSrc}\\n+ alt={alt}\\n+ className={className}\\n+ style={{\\n+ backgroundColor: \\\"transparent\\\",\\n+ border: \\\"none\\\",\\n+ outline: \\\"none\\\",\\n+ ...style,\\n+ }}\\n+ onLoad={onLoad}\\n+ {...props}\\n+ />\\n+ );\\n+ },\\n+ (prevProps, nextProps) => {\\n+ // Only re-render if src, alt, or node changes\\n+ // Note: The component will still handle src changes internally via useEffect\\n+ return (\\n+ prevProps.src === nextProps.src &&\\n+ prevProps.alt === nextProps.alt &&\\n+ prevProps.node === nextProps.node\\n+ );\\n+ },\\n+);\\n+\\n+export default ChatImage;\\ndiff --git a/src/components/images/ImagesPage.js b/src/components/images/ImagesPage.js\\nindex 90572b7..9c3871b 100644\\n--- a/src/components/images/ImagesPage.js\\n+++ b/src/components/images/ImagesPage.js\\n@@ -14,6 +14,7 @@ import {\\n TooltipContent,\\n TooltipProvider,\\n } from \\\"../../../@/components/ui/tooltip\\\";\\n+import ChatImage from \\\"./ChatImage\\\";\\n \\n function ImagesPage() {\\n const [prompt, setPrompt] = useState(\\\"\\\");\\n@@ -267,7 +268,7 @@ function ImagesPage() {\\n <Tooltip>\\n <TooltipTrigger asChild>\\n <button\\n- className=\\\"lb-icon-button\\\"\\n+ className=\\\"lb-icon-button text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:bg-transparent dark:border-gray-600 dark:hover:border-gray-500\\\"\\n disabled={selectedImages.size === 0}\\n onClick={() => handleBulkAction(\\\"download\\\")}\\n >\\n@@ -282,7 +283,7 @@ function ImagesPage() {\\n <Tooltip>\\n <TooltipTrigger asChild>\\n <button\\n- className=\\\"lb-icon-button\\\"\\n+ className=\\\"lb-icon-button text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:bg-transparent dark:border-gray-600 dark:hover:border-gray-500\\\"\\n disabled={selectedImages.size === 0}\\n onClick={() => handleBulkAction(\\\"delete\\\")}\\n >\\n@@ -299,7 +300,7 @@ function ImagesPage() {\\n <Tooltip>\\n <TooltipTrigger asChild>\\n <button\\n- className=\\\"lb-icon-button\\\"\\n+ className=\\\"lb-icon-button text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:bg-transparent dark:border-gray-600 dark:hover:border-gray-500\\\"\\n onClick={() => {\\n if (\\n window.confirm(\\n@@ -417,11 +418,12 @@ function ImageTile({\\n \\n <div className=\\\"image-wrapper\\\" onClick={onClick}>\\n {!expired && url && !loadError ? (\\n- <img\\n+ <ChatImage\\n src={url}\\n alt={prompt}\\n onError={() => setLoadError(true)}\\n onLoad={() => setLoadError(false)}\\n+ className=\\\"w-full h-full object-cover object-center\\\"\\n />\\n ) : (\\n <div className=\\\"h-full bg-gray-50 p-4 text-sm flex items-center justify-center\\\">\\n@@ -443,7 +445,7 @@ function ImageTile({\\n \\n <div className=\\\"image-actions\\\">\\n <button\\n- className=\\\"lb-sm lb-outline-secondary\\\"\\n+ className=\\\"lb-icon-button text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:bg-transparent dark:border-gray-600 dark:hover:border-gray-500\\\"\\n title={t(\\\"Download\\\")}\\n onClick={(e) => {\\n e.stopPropagation();\\n@@ -453,7 +455,7 @@ function ImageTile({\\n <FaDownload />\\n </button>\\n <button\\n- className=\\\"lb-sm lb-outline-secondary\\\"\\n+ className=\\\"lb-icon-button text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 dark:bg-transparent dark:border-gray-600 dark:hover:border-gray-500\\\"\\n title={t(\\\"Delete\\\")}\\n onClick={(e) => {\\n if (\\n@@ -566,8 +568,8 @@ function ImageModal({ show, image, onHide }) {\\n <Modal show={show} onHide={onHide} title={t(\\\"Generated image\\\")}>\\n <div className=\\\"flex flex-col gap-4 sm:flex-row sm:gap-6\\\">\\n <div className=\\\"sm:basis-9/12\\\">\\n- <img\\n- className=\\\"rounded-md\\\"\\n+ <ChatImage\\n+ className=\\\"rounded-md w-full\\\"\\n src={image?.url}\\n alt={image?.prompt}\\n />\\ndiff --git a/src/components/notifications/NotificationButton.js b/src/components/notifications/NotificationButton.js\\nnew file mode 100644\\nindex 0000000..da62573\\n--- /dev/null\\n+++ b/src/components/notifications/NotificationButton.js\\n@@ -0,0 +1,330 @@\\n+import {\\n+ AlertDialog,\\n+ AlertDialogAction,\\n+ AlertDialogCancel,\\n+ AlertDialogContent,\\n+ AlertDialogDescription,\\n+ AlertDialogFooter,\\n+ AlertDialogHeader,\\n+ AlertDialogTitle,\\n+} from \\\"@/components/ui/alert-dialog\\\";\\n+import {\\n+ Popover,\\n+ PopoverContent,\\n+ PopoverTrigger,\\n+} from \\\"@/components/ui/popover\\\";\\n+import { BellIcon } from \\\"@heroicons/react/24/outline\\\";\\n+import { BanIcon, Check, EyeOff, XIcon } from \\\"lucide-react\\\";\\n+import { useRouter } from \\\"next/navigation\\\";\\n+import { useCallback, useContext, useState } from \\\"react\\\";\\n+import { useTranslation } from \\\"react-i18next\\\";\\n+import TimeAgo from \\\"react-time-ago\\\";\\n+import stringcase from \\\"stringcase\\\";\\n+import Loader from \\\"../../../app/components/loader\\\";\\n+import {\\n+ useCancelRequest,\\n+ useDismissNotification,\\n+ useNotifications,\\n+} from \\\"../../../app/queries/notifications\\\";\\n+import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import { useNotificationsContext } from \\\"../../contexts/NotificationContext\\\";\\n+\\n+export const NotificationDisplayType = {\\n+ \\\"video-translate\\\": \\\"Video translation\\\",\\n+};\\n+\\n+const getLocaleShortName = (locale, usersLanguage) => {\\n+ try {\\n+ return new Intl.DisplayNames([usersLanguage], { type: \\\"language\\\" }).of(\\n+ locale?.split(\\\"-\\\")[0],\\n+ );\\n+ } catch (e) {\\n+ return locale; // fallback to code if translation fails\\n+ }\\n+};\\n+\\n+// Add status icons/colors mapping\\n+export const StatusIndicator = ({ status }) => {\\n+ if (status === \\\"failed\\\") {\\n+ return <BanIcon className=\\\"h-4 w-4 text-red-500\\\" />;\\n+ } else if (status === \\\"completed\\\") {\\n+ return <Check className=\\\"h-4 w-4 text-green-500\\\" />;\\n+ } else if (status === \\\"in_progress\\\") {\\n+ return <Loader size=\\\"small\\\" delay={0} />;\\n+ } else if (status === \\\"cancelled\\\") {\\n+ return <BanIcon className=\\\"h-4 w-4 text-red-500\\\" />;\\n+ } else {\\n+ return \\\"Unknown\\\";\\n+ }\\n+};\\n+\\n+export const getStatusColorClass = (status) => {\\n+ switch (status) {\\n+ case \\\"completed\\\":\\n+ return \\\"text-green-500\\\";\\n+ case \\\"failed\\\":\\n+ case \\\"cancelled\\\":\\n+ return \\\"text-red-500\\\";\\n+ case \\\"in_progress\\\":\\n+ return \\\"text-sky-500\\\";\\n+ default:\\n+ return \\\"text-gray-500\\\";\\n+ }\\n+};\\n+\\n+export default function NotificationButton() {\\n+ const { t } = useTranslation();\\n+ const { isNotificationOpen, setIsNotificationOpen } =\\n+ useNotificationsContext();\\n+ const { data: notificationsData } = useNotifications();\\n+ const notifications = notificationsData?.requests || []; // Extract requests from the response\\n+ const dismissNotification = useDismissNotification();\\n+ const [dismissingIds, setDismissingIds] = useState(new Set());\\n+ const [cancelRequestId, setCancelRequestId] = useState(null);\\n+ const { language } = useContext(LanguageContext);\\n+ const router = useRouter();\\n+ const cancelRequest = useCancelRequest();\\n+\\n+ const handleDismiss = (requestId) => {\\n+ setDismissingIds((prev) => new Set([...prev, requestId]));\\n+ setTimeout(() => {\\n+ dismissNotification.mutate(requestId);\\n+ setDismissingIds((prev) => {\\n+ const next = new Set(prev);\\n+ next.delete(requestId);\\n+ return next;\\n+ });\\n+ }, 300);\\n+ };\\n+\\n+ const handleCancelRequest = (requestId) => {\\n+ setCancelRequestId(requestId);\\n+ };\\n+\\n+ const confirmCancel = useCallback(async () => {\\n+ if (cancelRequestId) {\\n+ await cancelRequest.mutate(cancelRequestId);\\n+ setCancelRequestId(null);\\n+ }\\n+ }, [cancelRequestId, cancelRequest]);\\n+\\n+ return (\\n+ <>\\n+ <Popover\\n+ open={isNotificationOpen}\\n+ onOpenChange={setIsNotificationOpen}\\n+ >\\n+ <PopoverTrigger className=\\\"relative\\\">\\n+ <BellIcon\\n+ className=\\\"h-6 w-6 text-gray-500 hover:text-gray-700\\\"\\n+ stroke=\\\"#0284c7\\\"\\n+ fill={isNotificationOpen ? \\\"#0284c7\\\" : \\\"none\\\"}\\n+ />\\n+ {notifications.filter((n) => n.status === \\\"in_progress\\\")\\n+ .length > 0 && (\\n+ <>\\n+ <span className=\\\"absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 animate-ping opacity-75\\\" />\\n+ <span className=\\\"absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-xs text-white flex items-center justify-center\\\">\\n+ {\\n+ notifications.filter(\\n+ (n) => n.status === \\\"in_progress\\\",\\n+ ).length\\n+ }\\n+ </span>\\n+ </>\\n+ )}\\n+ </PopoverTrigger>\\n+ <PopoverContent className=\\\"w-80\\\">\\n+ <div className=\\\"space-y-4\\\">\\n+ <h3 className=\\\"font-medium\\\">{t(\\\"Notifications\\\")}</h3>\\n+ <div className=\\\"max-h-[300px] overflow-y-auto\\\">\\n+ {notifications.length === 0 ? (\\n+ <p className=\\\"text-sm text-gray-500\\\">\\n+ {t(\\\"No recent or active notifications\\\")}\\n+ </p>\\n+ ) : (\\n+ <div className=\\\"relative\\\">\\n+ {notifications.map((notification) => (\\n+ <div\\n+ key={notification.requestId}\\n+ data-request-id={\\n+ notification.requestId\\n+ }\\n+ className={`\\n+ space-y-2 bg-gray-100 p-2 rounded-md mb-2 \\n+ transform transition-all duration-300 ease-in-out\\n+ ${dismissingIds.has(notification.requestId) ? \\\"opacity-0 -translate-y-2\\\" : \\\"opacity-100 translate-y-0\\\"}\\n+ `}\\n+ >\\n+ <div className=\\\"flex text-sm gap-3\\\">\\n+ <div className=\\\"ps-1 pt-1 basis-5\\\">\\n+ <StatusIndicator\\n+ status={\\n+ notification.status\\n+ }\\n+ />\\n+ </div>\\n+ <div className=\\\"flex flex-col overflow-hidden grow\\\">\\n+ <span className=\\\"font-semibold text-gray-800\\\">\\n+ {t(\\n+ NotificationDisplayType[\\n+ notification\\n+ .type\\n+ ],\\n+ )}\\n+ </span>\\n+ {notification.metadata && (\\n+ <div\\n+ className=\\\"text-xs text-gray-600 truncate\\\"\\n+ title={\\n+ notification.statusText\\n+ }\\n+ >\\n+ {t(\\n+ \\\"{{from}} to {{to}}\\\",\\n+ {\\n+ from: getLocaleShortName(\\n+ notification\\n+ .metadata\\n+ .sourceLocale,\\n+ language,\\n+ ),\\n+ to: getLocaleShortName(\\n+ notification\\n+ .metadata\\n+ .targetLocale,\\n+ language,\\n+ ),\\n+ },\\n+ )}\\n+ </div>\\n+ )}\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <span className=\\\"text-xs text-gray-500\\\">\\n+ {\\n+ notification.statusText\\n+ }\\n+ </span>\\n+ )}\\n+ {notification.status ===\\n+ \\\"failed\\\" && (\\n+ <span className=\\\"text-xs text-red-500\\\">\\n+ {notification.statusText ||\\n+ t(\\n+ \\\"Request failed\\\",\\n+ )}\\n+ </span>\\n+ )}\\n+ <span\\n+ className={`text-xs font-semibold ${getStatusColorClass(notification.status)}`}\\n+ >\\n+ {t(\\n+ stringcase.sentencecase(\\n+ notification.status,\\n+ ),\\n+ )}\\n+ </span>\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <div className=\\\"my-1 h-2 w-full bg-gray-200 rounded-full\\\">\\n+ <div\\n+ className=\\\"h-full bg-sky-600 rounded-full transition-all duration-300\\\"\\n+ style={{\\n+ width: `${notification.progress * 100}%`,\\n+ }}\\n+ />\\n+ </div>\\n+ )}\\n+ {notification.createdAt && (\\n+ <span className=\\\"text-xs text-gray-500\\\">\\n+ {t(\\\"Created \\\")}{\\\" \\\"}\\n+ <TimeAgo\\n+ date={\\n+ notification.createdAt\\n+ }\\n+ />\\n+ </span>\\n+ )}\\n+ </div>\\n+ <div className=\\\"flex gap-2\\\">\\n+ {notification.status ===\\n+ \\\"in_progress\\\" && (\\n+ <button\\n+ onClick={() =>\\n+ handleCancelRequest(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 hover:bg-gray-100 rounded flex items-start\\\"\\n+ title={t(\\\"Cancel\\\")}\\n+ >\\n+ <XIcon className=\\\"h-4 w-4 text-gray-500\\\" />\\n+ </button>\\n+ )}\\n+ {(notification.status ===\\n+ \\\"completed\\\" ||\\n+ notification.status ===\\n+ \\\"failed\\\" ||\\n+ notification.status ===\\n+ \\\"cancelled\\\") && (\\n+ <button\\n+ onClick={() =>\\n+ handleDismiss(\\n+ notification.requestId,\\n+ )\\n+ }\\n+ className=\\\"p-1 hover:bg-gray-100 rounded flex items-start\\\"\\n+ title={t(\\\"Hide\\\")}\\n+ >\\n+ <EyeOff className=\\\"h-4 w-4 text-gray-500\\\" />\\n+ </button>\\n+ )}\\n+ </div>\\n+ </div>\\n+ </div>\\n+ ))}\\n+ </div>\\n+ )}\\n+ </div>\\n+ <div className=\\\"flex justify-end\\\">\\n+ <button\\n+ className=\\\"text-sm text-sky-500 hover:text-sky-600\\\"\\n+ onClick={() => {\\n+ router.push(\\\"/notifications\\\");\\n+ setIsNotificationOpen(false);\\n+ }}\\n+ >\\n+ {t(\\\"View history\\\")}\\n+ </button>\\n+ </div>\\n+ </div>\\n+ </PopoverContent>\\n+ </Popover>\\n+\\n+ <AlertDialog\\n+ open={!!cancelRequestId}\\n+ onOpenChange={() => setCancelRequestId(null)}\\n+ >\\n+ <AlertDialogContent>\\n+ <AlertDialogHeader>\\n+ <AlertDialogTitle>\\n+ {t(\\\"Confirm Cancellation\\\")}\\n+ </AlertDialogTitle>\\n+ <AlertDialogDescription>\\n+ {t(\\n+ \\\"Are you sure you want to cancel this request? This action cannot be undone.\\\",\\n+ )}\\n+ </AlertDialogDescription>\\n+ </AlertDialogHeader>\\n+ <AlertDialogFooter>\\n+ <AlertDialogCancel>{t(\\\"No\\\")}</AlertDialogCancel>\\n+ <AlertDialogAction onClick={confirmCancel}>\\n+ {t(\\\"Yes, Cancel Request\\\")}\\n+ </AlertDialogAction>\\n+ </AlertDialogFooter>\\n+ </AlertDialogContent>\\n+ </AlertDialog>\\n+ </>\\n+ );\\n+}\\ndiff --git a/src/components/sandbox/OutputSandbox.js b/src/components/sandbox/OutputSandbox.js\\nnew file mode 100644\\nindex 0000000..e89e30f\\n--- /dev/null\\n+++ b/src/components/sandbox/OutputSandbox.js\\n@@ -0,0 +1,129 @@\\n+import React, { useEffect, useRef, useState } from \\\"react\\\";\\n+\\n+export default function OutputSandbox({ content, height = \\\"300px\\\" }) {\\n+ const iframeRef = useRef(null);\\n+ const [isLoading, setIsLoading] = useState(true);\\n+ const resizeObserverRef = useRef(null);\\n+\\n+ useEffect(() => {\\n+ if (!iframeRef.current) return;\\n+\\n+ const iframe = iframeRef.current;\\n+ const setupFrame = async () => {\\n+ try {\\n+ setIsLoading(true);\\n+\\n+ // Create a base tag to handle relative URLs\\n+ const base = document.createElement(\\\"base\\\");\\n+ base.href = window.location.origin;\\n+\\n+ // Create proper HTML structure\\n+ const html = `\\n+ <!DOCTYPE html>\\n+ <html>\\n+ <head>\\n+ <meta charset=\\\"utf-8\\\">\\n+ <meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\">\\n+ <style>\\n+ body { \\n+ margin: 0; \\n+ font-family: system-ui, -apple-system, sans-serif;\\n+ }\\n+ /* Ensure images don't overflow */\\n+ img { max-width: 100%; height: auto; }\\n+ </style>\\n+ </head>\\n+ <body>${content}</body>\\n+ </html>\\n+ `;\\n+\\n+ // Use srcdoc for better security and performance\\n+ iframe.srcdoc = html;\\n+\\n+ // Handle iframe load\\n+ iframe.onload = () => {\\n+ const frameDoc =\\n+ iframe.contentDocument || iframe.contentWindow.document;\\n+\\n+ // Clean up any existing observer\\n+ if (resizeObserverRef.current) {\\n+ resizeObserverRef.current.disconnect();\\n+ }\\n+\\n+ // Setup new resize observer\\n+ const resizeObserver = new ResizeObserver((entries) => {\\n+ for (const entry of entries) {\\n+ const height = Math.max(\\n+ entry.contentRect.height,\\n+ entry.target.scrollHeight,\\n+ );\\n+ iframe.style.height = `${height}px`;\\n+ }\\n+ });\\n+\\n+ // Ensure body exists before observing\\n+ if (frameDoc.body) {\\n+ resizeObserver.observe(frameDoc.body);\\n+ resizeObserverRef.current = resizeObserver;\\n+ }\\n+\\n+ // Setup message handling for iframe->parent communication\\n+ iframe.contentWindow.addEventListener(\\n+ \\\"message\\\",\\n+ (event) => {\\n+ if (event.origin !== window.location.origin) return;\\n+ // Handle messages from the iframe\\n+ console.log(\\\"Message from sandbox:\\\", event.data);\\n+ },\\n+ );\\n+\\n+ setIsLoading(false);\\n+ };\\n+\\n+ // Handle errors\\n+ iframe.onerror = (error) => {\\n+ console.error(\\\"Sandbox iframe error:\\\", error);\\n+ setIsLoading(false);\\n+ };\\n+ } catch (error) {\\n+ console.error(\\\"Error setting up sandbox:\\\", error);\\n+ setIsLoading(false);\\n+ }\\n+ };\\n+\\n+ setupFrame();\\n+\\n+ // Cleanup\\n+ return () => {\\n+ if (resizeObserverRef.current) {\\n+ resizeObserverRef.current.disconnect();\\n+ }\\n+ if (iframe.contentWindow) {\\n+ iframe.contentWindow.removeEventListener(\\\"message\\\", () => {});\\n+ }\\n+ };\\n+ }, [content]);\\n+\\n+ return (\\n+ <div className=\\\"relative\\\">\\n+ {isLoading && (\\n+ <div className=\\\"absolute inset-0 flex items-center justify-center bg-gray-50\\\">\\n+ <div className=\\\"text-gray-500\\\">Loading...</div>\\n+ </div>\\n+ )}\\n+ <iframe\\n+ ref={iframeRef}\\n+ style={{\\n+ width: \\\"100%\\\",\\n+ height,\\n+ border: \\\"none\\\",\\n+ backgroundColor: \\\"transparent\\\",\\n+ opacity: isLoading ? 0 : 1,\\n+ transition: \\\"opacity 0.2s\\\",\\n+ }}\\n+ sandbox=\\\"allow-scripts allow-popups allow-forms allow-same-origin allow-downloads allow-presentation\\\"\\n+ title=\\\"Output Sandbox\\\"\\n+ />\\n+ </div>\\n+ );\\n+}\\ndiff --git a/src/components/transcribe/AddTrackOptions.js b/src/components/transcribe/AddTrackOptions.js\\nindex 588ebc6..c981601 100644\\n--- a/src/components/transcribe/AddTrackOptions.js\\n+++ b/src/components/transcribe/AddTrackOptions.js\\n@@ -7,20 +7,17 @@ import {\\n UploadIcon,\\n VideoIcon,\\n } from \\\"lucide-react\\\";\\n-import { useCallback, useContext, useRef, useState } from \\\"react\\\";\\n+import { useCallback, useContext, useEffect, useRef, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n import { FaVideo } from \\\"react-icons/fa\\\";\\n-import { AuthContext, ServerContext } from \\\"../../App\\\";\\n+import { AuthContext } from \\\"../../App\\\";\\n+import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n import { useProgress } from \\\"../../contexts/ProgressContext\\\";\\n import { QUERIES } from \\\"../../graphql\\\";\\n+import { isYoutubeUrl } from \\\"../../utils/urlUtils\\\";\\n import LoadingButton from \\\"../editor/LoadingButton\\\";\\n import TranslationOptions from \\\"./TranslationOptions\\\";\\n-import {\\n- convertSrtToVtt,\\n- detectSubtitleFormat,\\n- normalizeVtt,\\n-} from \\\"./transcribe.utils\\\";\\n-import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import { parse, build } from \\\"@aj-archipelago/subvibe\\\";\\n \\n export function AddTrackOptions({\\n url,\\n@@ -52,7 +49,9 @@ export function AddTrackOptions({\\n \\n return (\\n <Tabs defaultValue={defaultTab || visibleOptions[0]} className=\\\"w-full\\\">\\n- <TabsList className={`grid w-full ${gridCols} mb-4`}>\\n+ <TabsList\\n+ className={`grid w-full grid-cols-2 sm:${gridCols} mb-4 h-auto`}\\n+ >\\n {options.includes(\\\"transcribe\\\") && (\\n <TabsTrigger value=\\\"transcribe\\\">\\n <VideoIcon className=\\\"h-4 w-4 me-2\\\" />\\n@@ -179,13 +178,11 @@ function SubtitleUpload({ onAdd }) {\\n reader.onload = async (e) => {\\n let text = e.target.result;\\n \\n+ const parsed = parse(text);\\n+\\n if (fileExtension === \\\"srt\\\") {\\n- console.log(\\n- \\\"fileExtension\\\",\\n- fileExtension,\\n- convertSrtToVtt(text),\\n- );\\n- text = convertSrtToVtt(text);\\n+ // Convert SRT to VTT format\\n+ text = build(parsed.cues, \\\"vtt\\\");\\n }\\n \\n onAdd({\\n@@ -238,17 +235,20 @@ function ClipboardPaste({ onAdd }) {\\n if (!text.trim()) return;\\n \\n // Detect if the pasted text is in a subtitle format\\n- const format = detectSubtitleFormat(text);\\n+ const parsed = parse(text);\\n+ const format = parsed?.type;\\n+ const cues = parsed?.cues;\\n+\\n let processedText = text;\\n let outputFormat = \\\"\\\";\\n let name = t(\\\"Pasted Transcript\\\");\\n \\n if (format === \\\"srt\\\") {\\n- processedText = convertSrtToVtt(text);\\n+ processedText = build(cues, \\\"vtt\\\");\\n outputFormat = \\\"vtt\\\";\\n name = t(\\\"Pasted Subtitles\\\");\\n } else if (format === \\\"vtt\\\") {\\n- processedText = normalizeVtt(text);\\n+ processedText = build(cues, \\\"vtt\\\");\\n outputFormat = \\\"vtt\\\";\\n name = t(\\\"Pasted Subtitles\\\");\\n }\\n@@ -283,6 +283,19 @@ function ClipboardPaste({ onAdd }) {\\n );\\n }\\n \\n+export const getTranscribeQuery = (modelOption) => {\\n+ switch (modelOption?.toLowerCase()) {\\n+ case \\\"neuralSpace\\\":\\n+ return QUERIES.TRANSCRIBE_NEURALSPACE;\\n+ case \\\"gemini\\\":\\n+ return QUERIES.TRANSCRIBE_GEMINI;\\n+ case \\\"whisper\\\":\\n+ return QUERIES.TRANSCRIBE;\\n+ default:\\n+ return QUERIES.TRANSCRIBE;\\n+ }\\n+};\\n+\\n export default function TranscribeVideo({\\n url,\\n onAdd,\\n@@ -291,12 +304,14 @@ export default function TranscribeVideo({\\n onClose,\\n }) {\\n const { t } = useTranslation();\\n- const { neuralspaceEnabled } = useContext(ServerContext);\\n+ const isYouTubeVideo = url ? isYoutubeUrl(url) : false;\\n \\n- // Move state variables from Video.js\\n const [language, setLanguage] = useState(\\\"\\\");\\n- const [selectedModelOption, setSelectedModelOption] = useState(\\\"Whisper\\\");\\n+ const [selectedModelOption, setSelectedModelOption] = useState(\\n+ isYouTubeVideo ? \\\"Gemini\\\" : \\\"Whisper\\\",\\n+ );\\n const [transcriptionOption, setTranscriptionOption] = useState(null);\\n+ // eslint-disable-next-line no-unused-vars\\n const [requestId, setRequestId] = useState(null);\\n const [loading, setLoading] = useState(false);\\n const [currentOperation, setCurrentOperation] = useState(\\\"\\\");\\n@@ -313,99 +328,99 @@ export default function TranscribeVideo({\\n highlightWords,\\n } = transcriptionOption ?? {};\\n \\n- // Move handleSubmit from Video.js\\n- const handleSubmit = useCallback(\\n- async () => {\\n- if (!url || loading) return;\\n-\\n- setCurrentOperation(t(\\\"Transcribing\\\"));\\n- try {\\n- setLoading(true);\\n-\\n- const _query =\\n- selectedModelOption === \\\"NeuralSpace\\\"\\n- ? QUERIES.TRANSCRIBE_NEURALSPACE\\n- : QUERIES.TRANSCRIBE;\\n-\\n- const { data } = await apolloClient.query({\\n- query: _query,\\n- variables: {\\n- file: url,\\n- language,\\n- wordTimestamped,\\n- responseFormat:\\n- responseFormat !== \\\"formatted\\\"\\n- ? responseFormat\\n- : null,\\n- maxLineCount,\\n- maxLineWidth,\\n- maxWordsPerLine,\\n- highlightWords,\\n- async: true,\\n- },\\n- fetchPolicy: \\\"network-only\\\",\\n- });\\n-\\n- const dataResult =\\n- data?.transcribe?.result ||\\n- data?.transcribe_neuralspace?.result;\\n-\\n- if (dataResult) {\\n- setRequestId(dataResult);\\n- addProgressToast(\\n- dataResult,\\n- t(\\\"Transcribing\\\") + \\\"...\\\",\\n- async (finalData) => {\\n- if (responseFormat === \\\"formatted\\\") {\\n- const response = await apolloClient.query({\\n- query: QUERIES.FORMAT_PARAGRAPH_TURBO,\\n- variables: {\\n- text: finalData,\\n- async: false,\\n- },\\n- });\\n-\\n- finalData =\\n- response.data?.format_paragraph_turbo\\n- ?.result;\\n- }\\n- setLoading(false);\\n- onAdd({\\n- text: finalData,\\n- format: responseFormat,\\n- name:\\n- responseFormat === \\\"vtt\\\"\\n- ? t(\\\"Subtitles\\\")\\n- : t(\\\"Transcript\\\"),\\n+ // Update model if URL changes and it's a YouTube video\\n+ useEffect(() => {\\n+ if (isYouTubeVideo) {\\n+ setSelectedModelOption(\\\"Gemini\\\");\\n+ }\\n+ }, [url, isYouTubeVideo]);\\n+\\n+ const handleSubmit = useCallback(async () => {\\n+ if (!url || loading) return;\\n+\\n+ setCurrentOperation(t(\\\"Transcribing\\\"));\\n+ try {\\n+ setLoading(true);\\n+\\n+ const _query = getTranscribeQuery(selectedModelOption);\\n+\\n+ const { data } = await apolloClient.query({\\n+ query: _query,\\n+ variables: {\\n+ file: url,\\n+ language,\\n+ wordTimestamped,\\n+ responseFormat:\\n+ responseFormat !== \\\"formatted\\\" ? responseFormat : null,\\n+ maxLineCount,\\n+ maxLineWidth,\\n+ maxWordsPerLine,\\n+ highlightWords,\\n+ async: true,\\n+ },\\n+ fetchPolicy: \\\"network-only\\\",\\n+ });\\n+\\n+ const dataResult =\\n+ data?.transcribe?.result ||\\n+ data?.transcribe_neuralspace?.result ||\\n+ data?.transcribe_gemini?.result;\\n+\\n+ if (dataResult) {\\n+ setRequestId(dataResult);\\n+ addProgressToast(\\n+ dataResult,\\n+ t(\\\"Transcribing\\\") + \\\"...\\\",\\n+ async (finalData) => {\\n+ if (responseFormat === \\\"formatted\\\") {\\n+ const response = await apolloClient.query({\\n+ query: QUERIES.FORMAT_PARAGRAPH_TURBO,\\n+ variables: {\\n+ text: finalData,\\n+ async: false,\\n+ },\\n });\\n- setRequestId(null);\\n- },\\n- );\\n- onClose?.();\\n- }\\n- } catch (e) {\\n- console.error(\\\"Transcription error:\\\", e);\\n- setError(e);\\n- setLoading(false);\\n+\\n+ finalData =\\n+ response.data?.format_paragraph_turbo?.result;\\n+ }\\n+ setLoading(false);\\n+ onAdd({\\n+ text: finalData,\\n+ format: responseFormat,\\n+ name:\\n+ responseFormat === \\\"vtt\\\"\\n+ ? t(\\\"Subtitles\\\")\\n+ : t(\\\"Transcript\\\"),\\n+ });\\n+ setRequestId(null);\\n+ },\\n+ );\\n+ onClose?.();\\n }\\n- },\\n+ } catch (e) {\\n+ console.error(\\\"Transcription error:\\\", e);\\n+ setError(e);\\n+ setLoading(false);\\n+ }\\n // eslint-disable-next-line react-hooks/exhaustive-deps\\n- [\\n- url,\\n- language,\\n- wordTimestamped,\\n- responseFormat,\\n- maxLineCount,\\n- maxLineWidth,\\n- maxWordsPerLine,\\n- highlightWords,\\n- loading,\\n- async,\\n- addProgressToast,\\n- t,\\n- onClose,\\n- ],\\n- );\\n+ }, [\\n+ url,\\n+ language,\\n+ wordTimestamped,\\n+ responseFormat,\\n+ maxLineCount,\\n+ maxLineWidth,\\n+ maxWordsPerLine,\\n+ highlightWords,\\n+ loading,\\n+ async,\\n+ addProgressToast,\\n+ t,\\n+ onClose,\\n+ apolloClient,\\n+ selectedModelOption,\\n+ ]);\\n \\n // Add logging for select changes\\n const handleFormatChange = (e) => {\\n@@ -456,16 +471,20 @@ export default function TranscribeVideo({\\n \\n return (\\n <>\\n- {neuralspaceEnabled && (\\n+ {/* <div>\\n <span className=\\\"flex items-center pb-2\\\">\\n- <label className=\\\"text-sm px-1\\\">{t(\\\"Using model\\\")}</label>\\n+ <label className=\\\"text-sm px-1 whitespace-nowrap\\\">\\n+ {t(\\\"Using model\\\")}\\n+ </label>\\n <ModelSelector\\n loading={loading}\\n selectedModelOption={selectedModelOption}\\n setSelectedModelOption={setSelectedModelOption}\\n+ neuralspaceEnabled={neuralspaceEnabled}\\n+ disabled={isYouTubeVideo}\\n />\\n </span>\\n- )}\\n+ </div> */}\\n \\n <div className=\\\"options-section flex flex-col justify-between gap-2 mb-5 p-2.5 border border-gray-300 rounded-md bg-neutral-100 w-full\\\">\\n <div className=\\\"flex flex-col\\\">\\n@@ -482,7 +501,7 @@ export default function TranscribeVideo({\\n {responseFormat === \\\"vtt\\\" && (\\n <div className={`flex flex-col`}>\\n <h5 className=\\\"font-semibold text-xs text-gray-400 mb-1\\\">\\n- Transcription type\\n+ {t(\\\"Transcription type\\\")}\\n </h5>\\n <TranscriptionTypeSelector\\n loading={loading}\\n@@ -491,6 +510,7 @@ export default function TranscribeVideo({\\n handleTranscriptionTypeChange={\\n handleTranscriptionTypeChange\\n }\\n+ selectedModelOption={selectedModelOption}\\n />\\n </div>\\n )}\\n@@ -508,6 +528,12 @@ export default function TranscribeVideo({\\n </div>\\n </div>\\n \\n+ {error && (\\n+ <div className=\\\"text-red-500 text-sm mb-2\\\">\\n+ {t(\\\"Error\\\")}: {error.message}\\n+ </div>\\n+ )}\\n+\\n <div className=\\\"\\\">\\n <LoadingButton\\n className=\\\"mb-2.5 lb-primary\\\"\\n@@ -540,31 +566,15 @@ function FormatSelector({ loading, responseFormat, handleFormatChange }) {\\n );\\n }\\n \\n-function ModelSelector({\\n- loading,\\n- selectedModelOption,\\n- setSelectedModelOption,\\n-}) {\\n- return (\\n- <select\\n- className=\\\"lb-select ml-2 w-auto flex-shrink-0\\\"\\n- disabled={loading}\\n- value={selectedModelOption}\\n- onChange={(e) => setSelectedModelOption(e.target.value)}\\n- >\\n- <option value=\\\"Whisper\\\">Whisper</option>\\n- <option value=\\\"NeuralSpace\\\">NeuralSpace</option>\\n- </select>\\n- );\\n-}\\n-\\n function TranscriptionTypeSelector({\\n loading,\\n wordTimestamped,\\n maxLineWidth,\\n handleTranscriptionTypeChange,\\n+ selectedModelOption,\\n }) {\\n const { t } = useTranslation();\\n+ const isGemini = selectedModelOption?.toLowerCase() === \\\"gemini\\\";\\n \\n return (\\n <select\\n@@ -584,7 +594,7 @@ function TranscriptionTypeSelector({\\n onChange={handleTranscriptionTypeChange}\\n >\\n <option value=\\\"phraseLevel\\\">{t(\\\"Phrase level\\\")}</option>\\n- <option value=\\\"wordLevel\\\">{t(\\\"Word level\\\")}</option>\\n+ {!isGemini && <option value=\\\"wordLevel\\\">{t(\\\"Word level\\\")}</option>}\\n <option value=\\\"horizontal\\\">{t(\\\"Horizontal\\\")}</option>\\n <option value=\\\"vertical\\\">{t(\\\"Vertical\\\")}</option>\\n </select>\\ndiff --git a/src/components/transcribe/AzureVideoTranslate.js b/src/components/transcribe/AzureVideoTranslate.js\\nindex a16b175..591d0f3 100644\\n--- a/src/components/transcribe/AzureVideoTranslate.js\\n+++ b/src/components/transcribe/AzureVideoTranslate.js\\n@@ -1,65 +1,53 @@\\n-import { useApolloClient } from \\\"@apollo/client\\\";\\n+import axios from \\\"axios\\\";\\n import { LanguagesIcon } from \\\"lucide-react\\\";\\n import { useContext, useState } from \\\"react\\\";\\n-import { useProgress } from \\\"../../contexts/ProgressContext\\\";\\n-import { AZURE_VIDEO_TRANSLATE } from \\\"../../graphql\\\";\\n-import { LOCALES } from \\\"../../utils/constants\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n+import { toast } from \\\"react-toastify\\\";\\n import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import { useNotificationsContext } from \\\"../../contexts/NotificationContext\\\";\\n+import { LOCALES } from \\\"../../utils/constants\\\";\\n+import { useNotifications } from \\\"../../../app/queries/notifications\\\";\\n \\n-export default function AzureVideoTranslate({ url, onQueued, onComplete }) {\\n- const apolloClient = useApolloClient();\\n+export default function AzureVideoTranslate({ url, onQueued }) {\\n const [sourceLocale, setSourceLocale] = useState(\\\"en-US\\\");\\n const [targetLocale, setTargetLocale] = useState(\\\"ar-QA\\\");\\n- const { addProgressToast } = useProgress();\\n const { t } = useTranslation();\\n const { language } = useContext(LanguageContext);\\n+ const { openNotifications } = useNotificationsContext();\\n+ const { invalidateNotifications } = useNotifications();\\n \\n- async function setFinalDataPre(data) {\\n- if (data === \\\"[DONE]\\\") {\\n- console.log(\\\"[DONE] received\\\");\\n- throw new Error(\\n- \\\"There was an unknown error returned by the translation service. Please try again.\\\",\\n- );\\n- }\\n-\\n- // Parse the data - handle both single and double JSON stringified cases\\n+ const handleSubmit = async () => {\\n try {\\n- data = JSON.parse(data);\\n- // Check if it's still a string and potentially another JSON\\n- if (typeof data === \\\"string\\\") {\\n- data = JSON.parse(data);\\n- }\\n- } catch (e) {\\n- console.error(\\\"Error parsing JSON response:\\\", e);\\n- throw new Error(\\\"Failed to parse translation service response\\\");\\n- }\\n+ const { data } = await axios.post(\\\"/api/azure-video-translate\\\", {\\n+ sourceLocale,\\n+ targetLocale,\\n+ targetLocaleLabel: new Intl.DisplayNames([language], {\\n+ type: \\\"language\\\",\\n+ }).of(targetLocale),\\n+ url,\\n+ });\\n \\n- try {\\n- const defaultSubtitlesUrl = data.outputVideoSubtitleWebVttFileUrl;\\n- const targetVideoUrl =\\n- data.targetLocales[targetLocale].outputVideoFileUrl;\\n- const targetSubtitlesUrl =\\n- data.targetLocales[targetLocale]\\n- .outputVideoSubtitleWebVttFileUrl;\\n+ const requestId = data;\\n \\n- onComplete?.(targetLocale, targetVideoUrl, {\\n- original: defaultSubtitlesUrl,\\n- translated: targetSubtitlesUrl,\\n- });\\n- } catch (e) {\\n- console.error(e);\\n- throw e;\\n+ // Invalidate notifications to trigger a refetch\\n+ invalidateNotifications();\\n+ // Open notifications panel\\n+ openNotifications();\\n+\\n+ onQueued?.(requestId);\\n+ } catch (error) {\\n+ console.error(\\\"Error translating video:\\\", error);\\n+ toast.error(\\\"Error queuing video translation\\\");\\n }\\n- }\\n+ };\\n \\n return (\\n <>\\n <div className=\\\"mt-2 p-2 border-t border-b rounded border-gray-200 pt-4 bg-opacity-90 bg-neutral-100 shadow\\\">\\n <div className=\\\"flex items-end gap-3\\\">\\n- <div>\\n- <label htmlFor=\\\"sourceLocaleSelect\\\">\\n- {t(\\\"Source Locale\\\")}\\n+ <div className=\\\"flex flex-col gap-1\\\">\\n+ <label className=\\\"text-sm\\\" htmlFor=\\\"sourceLocaleSelect\\\">\\n+ {t(\\\"Original\\\")}\\n </label>\\n <select\\n id=\\\"sourceLocaleSelect\\\"\\n@@ -77,9 +65,9 @@ export default function AzureVideoTranslate({ url, onQueued, onComplete }) {\\n </select>\\n </div>\\n \\n- <div>\\n- <label htmlFor=\\\"targetLocaleSelect\\\">\\n- {t(\\\"Target Locale\\\")}\\n+ <div className=\\\"flex flex-col gap-1\\\">\\n+ <label className=\\\"text-sm\\\" htmlFor=\\\"targetLocaleSelect\\\">\\n+ {t(\\\"Translate to\\\")}\\n </label>\\n <select\\n className=\\\"lb-select\\\"\\n@@ -104,34 +92,7 @@ export default function AzureVideoTranslate({ url, onQueued, onComplete }) {\\n <button\\n disabled={!url}\\n className=\\\"lb-primary\\\"\\n- onClick={async () => {\\n- const { data } = await apolloClient.query(\\n- {\\n- query: AZURE_VIDEO_TRANSLATE,\\n- variables: {\\n- mode: \\\"uploadvideooraudiofileandcreatetranslation\\\",\\n- sourcelocale: sourceLocale,\\n- targetlocale: targetLocale,\\n- sourcevideooraudiofilepath: url,\\n- stream: true,\\n- },\\n- },\\n- { fetchPolicy: \\\"no-cache\\\" },\\n- );\\n- const requestId = data.azure_video_translate.result;\\n- addProgressToast(\\n- requestId,\\n- t(\\\"Translating video to {{locale}}\\\", {\\n- locale: new Intl.DisplayNames([language], {\\n- type: \\\"language\\\",\\n- }).of(targetLocale),\\n- }),\\n- setFinalDataPre,\\n- () => {},\\n- 60 * 1000, // consider it failed if no heartbeats are received\\n- );\\n- onQueued?.(requestId);\\n- }}\\n+ onClick={handleSubmit}\\n >\\n <LanguagesIcon className=\\\"w-4 h-4 me-1\\\" />\\n {t(\\\"Translate Video\\\")}\\ndiff --git a/src/components/transcribe/InitialView.js b/src/components/transcribe/InitialView.js\\nindex 1c6cdf9..5afd989 100644\\n--- a/src/components/transcribe/InitialView.js\\n+++ b/src/components/transcribe/InitialView.js\\n@@ -8,11 +8,10 @@ import {\\n DialogTitle,\\n } from \\\"@/components/ui/dialog\\\";\\n import { TextIcon, VideoIcon } from \\\"lucide-react\\\";\\n-import { useContext, useState } from \\\"react\\\";\\n+import { useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n import AddTrackDialog from \\\"./AddTrackDialog\\\";\\n import VideoInput from \\\"./VideoInput\\\";\\n-import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n \\n export default function InitialView({\\n setAddTrackDialogOpen,\\n@@ -29,7 +28,6 @@ export default function InitialView({\\n }) {\\n const { t } = useTranslation();\\n const [showVideoInput, setShowVideoInput] = useState(false);\\n- const { direction } = useContext(LanguageContext);\\n \\n return (\\n <>\\ndiff --git a/src/components/transcribe/TaxonomySelector.js b/src/components/transcribe/TaxonomySelector.js\\nindex 2c4bffe..0128725 100644\\n--- a/src/components/transcribe/TaxonomySelector.js\\n+++ b/src/components/transcribe/TaxonomySelector.js\\n@@ -49,7 +49,6 @@ function TaxonomySelector({ text }) {\\n );\\n })\\n .catch((e) => {\\n- console.log(\\\"failed\\\", e);\\n setError(e);\\n });\\n }, []);\\ndiff --git a/src/components/transcribe/TranscriptView.js b/src/components/transcribe/TranscriptView.js\\nindex 808d361..789f7f8 100644\\n--- a/src/components/transcribe/TranscriptView.js\\n+++ b/src/components/transcribe/TranscriptView.js\\n@@ -5,59 +5,22 @@ import { useTranslation } from \\\"react-i18next\\\";\\n import { FaEdit } from \\\"react-icons/fa\\\";\\n import TextareaAutosize from \\\"react-textarea-autosize\\\";\\n import CopyButton from \\\"../CopyButton\\\";\\n+import { parse, formatTimestamp } from \\\"@aj-archipelago/subvibe\\\";\\n+import { RefreshCw } from \\\"lucide-react\\\";\\n+import { isYoutubeUrl } from \\\"../../utils/urlUtils\\\";\\n \\n // Simplified VTT component\\n function VttSubtitles({ name, text, onSeek, currentTime, onTextChange }) {\\n const containerRef = useRef(null);\\n- const lines = text.split(\\\"\\\\n\\\");\\n- const subtitles = [];\\n- let currentSubtitle = {};\\n+ const parsed = parse(text);\\n \\n- let isInSubtitle = false;\\n- let isHeader = true;\\n- lines.forEach((line) => {\\n- line = line.trim();\\n-\\n- // Handle header\\n- if (isHeader) {\\n- if (line === \\\"WEBVTT\\\") {\\n- return;\\n- }\\n- if (!line) {\\n- isHeader = false;\\n- return;\\n- }\\n- return;\\n- }\\n-\\n- // Skip empty lines and numeric identifiers\\n- if (!line || /^\\\\d+$/.test(line)) {\\n- return;\\n- }\\n-\\n- const timestampMatch = line.match(\\n- /(\\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{3}) --> (\\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{3})/,\\n- );\\n- if (timestampMatch) {\\n- if (currentSubtitle.timestamp) {\\n- subtitles.push(currentSubtitle);\\n- }\\n- currentSubtitle = {\\n- timestamp: timestampMatch[1],\\n- endTimestamp: timestampMatch[2],\\n- text: \\\"\\\",\\n- };\\n- isInSubtitle = true;\\n- } else if (isInSubtitle && currentSubtitle.timestamp) {\\n- currentSubtitle.text = (currentSubtitle.text + \\\" \\\" + line).trim();\\n- }\\n+ const subtitles = parsed.cues;\\n+ subtitles.forEach((subtitle) => {\\n+ // Replace the integer startTime/endTime with formatted strings for UI display\\n+ subtitle.timestamp = formatTimestamp(subtitle.startTime);\\n+ subtitle.endTimestamp = formatTimestamp(subtitle.endTime);\\n });\\n \\n- // Add the last subtitle if it exists\\n- if (currentSubtitle.timestamp) {\\n- subtitles.push(currentSubtitle);\\n- }\\n-\\n // Add effect to handle scrolling when currentTime changes\\n useEffect(() => {\\n if (!containerRef.current || !currentTime) return;\\n@@ -113,7 +76,7 @@ function VttSubtitles({ name, text, onSeek, currentTime, onTextChange }) {\\n return (\\n <div\\n ref={containerRef}\\n- className=\\\"grid grid-cols-[auto,1fr] gap-x-4 gap-y-2 overflow-y-auto max-h-[500px] text-sm\\\"\\n+ className=\\\"grid sm:grid-cols-[auto,1fr] gap-x-4 gap-y-2 overflow-y-auto max-h-[500px] text-sm\\\"\\n >\\n {subtitles.map((subtitle, index) => (\\n <React.Fragment key={`${name}-${index}`}>\\n@@ -125,7 +88,7 @@ function VttSubtitles({ name, text, onSeek, currentTime, onTextChange }) {\\n onClick={() =>\\n handleTimestampClick(subtitle.timestamp)\\n }\\n- className=\\\"text-blue-600 hover:text-blue-800\\\"\\n+ className=\\\"text-sky-600 hover:text-sky-800\\\"\\n >\\n {subtitle.timestamp}\\n </button>\\n@@ -220,10 +183,18 @@ function TranscriptView({\\n onTextChange,\\n isEditing,\\n setIsEditing,\\n+ onRetranscribe,\\n+ isRetranscribing,\\n+ showRetranscribeButton = true,\\n+ url,\\n }) {\\n const { t } = useTranslation();\\n const [editableText, setEditableText] = useState(text);\\n \\n+ // Determine if we should show the retranscribe button\\n+ const shouldShowRetranscribeButton =\\n+ showRetranscribeButton && !isYoutubeUrl(url);\\n+\\n useEffect(() => {\\n setEditableText(text);\\n }, [text]);\\n@@ -239,10 +210,10 @@ function TranscriptView({\\n };\\n \\n return (\\n- <div className=\\\"transcription-taxonomy-container flex flex-col gap-2 overflow-y-auto mt-6\\\">\\n+ <div className=\\\"transcription-taxonomy-container flex flex-col gap-2 overflow-y-auto mt-2\\\">\\n <div className=\\\"transcription-section relative\\\">\\n {isEditing ? (\\n- <div className=\\\"border border-gray-300 rounded-md p-2.5 bg-gray-50\\\">\\n+ <div className=\\\"border border-gray-300 rounded-md p-2.5 bg-gray-50 mb-4\\\">\\n <textarea\\n value={editableText}\\n onChange={(e) => setEditableText(e.target.value)}\\n@@ -264,7 +235,7 @@ function TranscriptView({\\n </div>\\n </div>\\n ) : (\\n- <div className=\\\"border border-gray-300 rounded-md py-2.5 px-2.5 bg-gray-50\\\">\\n+ <div className=\\\"border border-gray-300 rounded-md py-2.5 px-2.5 bg-gray-50 mb-4\\\">\\n {format === \\\"vtt\\\" && text ? (\\n <VttSubtitles\\n name={name}\\n@@ -291,6 +262,25 @@ function TranscriptView({\\n />\\n )}\\n </div>\\n+\\n+ {/* Show retranscribe button only if shouldShowRetranscribeButton is true and not currently retranscribing */}\\n+ {!isRetranscribing && shouldShowRetranscribeButton && (\\n+ <div className=\\\"-mt-2 mb-4 text-xs flex flex-col sm:flex-row gap-1 sm:gap-2\\\">\\n+ <div className=\\\"text-gray-500\\\">\\n+ {t(\\\"Transcript not looking right?\\\")}\\n+ </div>\\n+ <button onClick={onRetranscribe} className=\\\"\\\">\\n+ <span className=\\\"flex gap-1\\\">\\n+ <RefreshCw className=\\\"h-3 w-3 text-gray-500\\\" />\\n+ <span className=\\\"text-sky-600 text-start\\\">\\n+ {t(\\n+ \\\"Transcribe again using an alternate model\\\",\\n+ )}\\n+ </span>\\n+ </span>\\n+ </button>\\n+ </div>\\n+ )}\\n </div>\\n </div>\\n );\\ndiff --git a/src/components/transcribe/TranslationOptions.js b/src/components/transcribe/TranslationOptions.js\\nindex 117ce9c..b570821 100644\\n--- a/src/components/transcribe/TranslationOptions.js\\n+++ b/src/components/transcribe/TranslationOptions.js\\n@@ -107,7 +107,7 @@ function TranslationOptions({\\n \\n return (\\n <div>\\n- <div className=\\\"flex items-center gap-2\\\">\\n+ <div className=\\\"flex flex-col sm:flex-row items-center gap-2\\\">\\n <div className=\\\"mb-3 basis-2/3\\\">\\n <h3 className=\\\"text-sm mb-1\\\">{t(\\\"From\\\")}</h3>\\n <Select\\ndiff --git a/src/components/transcribe/VideoInput.js b/src/components/transcribe/VideoInput.js\\nindex e911f7f..cf46596 100644\\n--- a/src/components/transcribe/VideoInput.js\\n+++ b/src/components/transcribe/VideoInput.js\\n@@ -8,11 +8,18 @@ import {\\n } from \\\"lucide-react\\\";\\n import { useContext, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n-import VideoSelector from \\\"./VideoSelector\\\";\\n-import { ServerContext } from \\\"../../App\\\";\\n import config from \\\"../../../config\\\";\\n-import { hashMediaFile, getVideoDuration } from \\\"../../utils/mediaUtils\\\";\\n+import { ServerContext } from \\\"../../App\\\";\\n import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import {\\n+ getVideoDuration,\\n+ hashMediaFile,\\n+ VIDEO_EXTENSIONS,\\n+ AUDIO_EXTENSIONS,\\n+ MEDIA_MIME_TYPES,\\n+} from \\\"../../utils/mediaUtils\\\";\\n+import { isYoutubeUrl } from \\\"../../utils/urlUtils\\\";\\n+import VideoSelector from \\\"./VideoSelector\\\";\\n \\n export const isValidUrl = (url) => {\\n try {\\n@@ -49,12 +56,17 @@ export const isCloudStorageUrl = (url) => {\\n };\\n \\n export const checkVideoUrl = async (url) => {\\n+ // Early return for YouTube URLs\\n+ if (isYoutubeUrl(url)) {\\n+ return true;\\n+ }\\n+\\n let video = null;\\n try {\\n- // Skip HEAD request for cloud storage URLs since they often have CORS restrictions\\n- if (!isCloudStorageUrl(url)) {\\n+ const isCloudUrl = isCloudStorageUrl(url);\\n+\\n+ if (!isCloudUrl) {\\n try {\\n- // First check if it's a valid video URL\\n const response = await fetch(url, { method: \\\"HEAD\\\" });\\n const contentType = response.headers.get(\\\"content-type\\\");\\n if (!contentType || !contentType.startsWith(\\\"video/\\\")) {\\n@@ -72,15 +84,17 @@ export const checkVideoUrl = async (url) => {\\n // Check video duration\\n video = document.createElement(\\\"video\\\");\\n video.preload = \\\"metadata\\\";\\n- video.crossOrigin = \\\"anonymous\\\"; // Add cross-origin attribute\\n+ video.crossOrigin = \\\"anonymous\\\";\\n \\n const durationPromise = new Promise((resolve, reject) => {\\n- video.onloadedmetadata = () => resolve(video.duration);\\n+ video.onloadedmetadata = () => {\\n+ resolve(video.duration);\\n+ };\\n video.onerror = (e) => {\\n- // If there's a CORS error but it's a cloud storage URL, we'll assume it's valid\\n- if (isCloudStorageUrl(url)) {\\n- resolve(0); // Resolve with 0 to skip duration check for cloud storage URLs\\n+ if (isCloudUrl) {\\n+ resolve(0);\\n } else {\\n+ console.error(\\\"❌ Video loading error:\\\", e);\\n reject(e);\\n }\\n };\\n@@ -105,7 +119,13 @@ export const checkVideoUrl = async (url) => {\\n }\\n };\\n \\n-function VideoInput({ url, setUrl, setVideoInformation }) {\\n+function VideoInput({\\n+ url,\\n+ setUrl,\\n+ setVideoInformation,\\n+ onUploadStart,\\n+ onUploadComplete,\\n+}) {\\n const { t } = useTranslation();\\n const [fileUploading, setFileUploading] = useState(false);\\n const [fileUploadError, setFileUploadError] = useState(null);\\n@@ -121,6 +141,7 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n setFileUploadError(null);\\n setUploadProgress(0);\\n setUrl(\\\"\\\");\\n+ onUploadStart?.(); // Notify parent that upload is starting\\n \\n const file = event.target.files[0];\\n \\n@@ -155,6 +176,7 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n transcriptionUrl: null,\\n });\\n setFileUploading(false);\\n+ onUploadComplete?.(); // Notify parent that upload is complete\\n return;\\n }\\n }\\n@@ -163,9 +185,13 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n // Continue with upload even if hash check fails\\n }\\n \\n- // Add file type validation\\n- const supportedVideoTypes = [\\\"video/mp4\\\", \\\"video/webm\\\", \\\"video/ogg\\\"];\\n- const supportedAudioTypes = [\\\"audio/mpeg\\\", \\\"audio/wav\\\", \\\"audio/ogg\\\"];\\n+ // Add file type validation using the imported MIME types\\n+ const supportedVideoTypes = MEDIA_MIME_TYPES.filter((type) =>\\n+ type.startsWith(\\\"video/\\\"),\\n+ );\\n+ const supportedAudioTypes = MEDIA_MIME_TYPES.filter((type) =>\\n+ type.startsWith(\\\"audio/\\\"),\\n+ );\\n \\n const isSupported = [\\n ...supportedVideoTypes,\\n@@ -216,12 +242,14 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n transcriptionUrl: null,\\n });\\n setFileUploading(false);\\n+ onUploadComplete?.(); // Notify parent that upload is complete\\n } else {\\n console.error(xhr.statusText);\\n setFileUploadError({\\n message: `${t(\\\"File upload failed, response:\\\")} ${xhr.statusText}`,\\n });\\n setFileUploading(false);\\n+ onUploadComplete?.(); // Notify parent that upload failed\\n }\\n };\\n \\n@@ -230,6 +258,7 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n console.error(error);\\n setFileUploadError({ message: t(\\\"File upload failed\\\") });\\n setFileUploading(false);\\n+ onUploadComplete?.(); // Notify parent that upload failed\\n };\\n \\n // Send the file\\n@@ -238,6 +267,7 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n console.error(error);\\n setFileUploadError({ message: t(\\\"File upload failed\\\") });\\n setFileUploading(false);\\n+ onUploadComplete?.(); // Notify parent that upload failed\\n }\\n };\\n \\n@@ -249,7 +279,14 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n \\n const result = await checkVideoUrl(url);\\n if (result === true) {\\n- setVideoInformation({ videoUrl: url, transcriptionUrl: null });\\n+ if (isYoutubeUrl(url)) {\\n+ setShowVideoSelector(true);\\n+ } else {\\n+ setVideoInformation({\\n+ videoUrl: url,\\n+ transcriptionUrl: null,\\n+ });\\n+ }\\n } else if (result === \\\"Video length exceeds 60 minutes\\\") {\\n setVideoSelectorError({\\n message: t(\\n@@ -263,7 +300,17 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n \\n return (\\n <div className=\\\"flex flex-col gap-2 mb-5\\\">\\n- {showVideoSelector ? (\\n+ {fileUploading ? (\\n+ <div className=\\\"flex flex-col items-center justify-center gap-4 py-8\\\">\\n+ <div className=\\\"flex items-center gap-2 text-sm text-gray-500\\\">\\n+ <Loader2Icon className=\\\"w-4 h-4 animate-spin\\\" />\\n+ <span>\\n+ {t(\\\"Processing media...\\\")}{\\\" \\\"}\\n+ {Math.round(uploadProgress)}%\\n+ </span>\\n+ </div>\\n+ </div>\\n+ ) : showVideoSelector ? (\\n <>\\n {videoSelectorError && (\\n <p className=\\\"text-red-500 text-sm\\\">\\n@@ -272,11 +319,16 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n )}\\n <VideoSelector\\n url={url}\\n+ onClose={() => setShowVideoSelector(false)}\\n onSelect={async (v) => {\\n try {\\n const result = await checkVideoUrl(v.videoUrl);\\n if (result === true) {\\n- setVideoInformation(v);\\n+ setUrl(v.videoUrl);\\n+ setVideoInformation({\\n+ videoUrl: v.videoUrl,\\n+ transcriptionUrl: v.transcriptionUrl,\\n+ });\\n setShowVideoSelector(false);\\n setVideoSelectorError(null);\\n } else if (\\n@@ -346,76 +398,89 @@ function VideoInput({ url, setUrl, setVideoInformation }) {\\n )}\\n </span>\\n \\n- <div className=\\\"flex items-center my-4 max-w-xl\\\">\\n- <div className=\\\"w-64 border-t border-gray-300\\\"></div>\\n- <span className=\\\"px-4 text-sm text-gray-500\\\">\\n- {t(\\\"OR\\\")}\\n- </span>\\n- <div className=\\\"flex-1 border-t border-gray-300\\\"></div>\\n+ <div className=\\\"flex justify-center w-full\\\">\\n+ <div className=\\\"flex items-center my-4\\\">\\n+ <div className=\\\"w-16 border-t border-gray-300\\\"></div>\\n+ <span className=\\\"px-4 text-sm text-gray-500\\\">\\n+ {t(\\\"OR\\\")}\\n+ </span>\\n+ <div className=\\\"flex-1 border-t border-gray-300 w-16\\\"></div>\\n+ </div>\\n </div>\\n \\n- <div className=\\\"flex flex-col gap-4\\\">\\n- <div\\n- className=\\\"border-2 border-dashed border-gray-300 rounded-lg p-8 w-full max-w-xl hover:border-primary-500 transition-colors\\\"\\n- onDragOver={(e) => {\\n- e.preventDefault();\\n- e.currentTarget.classList.add(\\n- \\\"border-primary-500\\\",\\n- );\\n- }}\\n- onDragLeave={(e) => {\\n- e.currentTarget.classList.remove(\\n- \\\"border-primary-500\\\",\\n- );\\n- }}\\n- onDrop={(e) => {\\n- e.preventDefault();\\n- e.currentTarget.classList.remove(\\n- \\\"border-primary-500\\\",\\n- );\\n- const file = e.dataTransfer.files[0];\\n- const event = { target: { files: [file] } };\\n- handleFileUpload(event);\\n- }}\\n- >\\n- <div className=\\\"text-center\\\">\\n- <label className=\\\"lb-outline-secondary text-sm flex gap-2 items-center cursor-pointer justify-center w-64 mx-auto mb-3\\\">\\n- <input\\n- type=\\\"file\\\"\\n- className=\\\"hidden\\\"\\n- accept=\\\"video/*,audio/*\\\"\\n- onChange={handleFileUpload}\\n- disabled={fileUploading}\\n- />\\n- {fileUploading ? (\\n- <>\\n- <Loader2Icon className=\\\"w-4 h-4 animate-spin\\\" />\\n- {t(\\\"Uploading...\\\")} {uploadProgress}\\n- %\\n- </>\\n- ) : (\\n- <>\\n- <UploadIcon className=\\\"w-4 h-4\\\" />\\n- {t(\\\"Choose a file\\\")}\\n- </>\\n- )}\\n- </label>\\n- <p className=\\\"text-sm text-gray-500 mb-2\\\">\\n- {t(\\\"or drag and drop here\\\")}\\n- </p>\\n- <p className=\\\"text-xs text-gray-400\\\">\\n- {t(\\\"Supported formats\\\")}: MP4, WebM, OGG,\\n- MP3, WAV\\n- <br />\\n- {t(\\\"Maximum file size\\\")}: 500MB\\n- </p>\\n+ <div className=\\\"flex justify-center w-full\\\">\\n+ <div className=\\\"flex flex-col gap-4\\\">\\n+ <div\\n+ className=\\\"border-2 border-dashed border-gray-300 rounded-lg p-8 w-full max-w-xl hover:border-primary-500 transition-colors\\\"\\n+ onDragOver={(e) => {\\n+ e.preventDefault();\\n+ e.currentTarget.classList.add(\\n+ \\\"border-primary-500\\\",\\n+ );\\n+ }}\\n+ onDragLeave={(e) => {\\n+ e.currentTarget.classList.remove(\\n+ \\\"border-primary-500\\\",\\n+ );\\n+ }}\\n+ onDrop={(e) => {\\n+ e.preventDefault();\\n+ e.currentTarget.classList.remove(\\n+ \\\"border-primary-500\\\",\\n+ );\\n+ const file = e.dataTransfer.files[0];\\n+ const event = { target: { files: [file] } };\\n+ handleFileUpload(event);\\n+ }}\\n+ >\\n+ <div className=\\\"text-center max-w-96\\\">\\n+ <label className=\\\"lb-outline-secondary text-sm flex gap-2 items-center cursor-pointer justify-center w-64 mx-auto mb-3\\\">\\n+ <input\\n+ type=\\\"file\\\"\\n+ className=\\\"hidden\\\"\\n+ accept=\\\"video/*,audio/*\\\"\\n+ onChange={handleFileUpload}\\n+ disabled={fileUploading}\\n+ />\\n+ {fileUploading ? (\\n+ <>\\n+ <Loader2Icon className=\\\"w-4 h-4 animate-spin\\\" />\\n+ {t(\\\"Uploading...\\\")}{\\\" \\\"}\\n+ {Math.round(uploadProgress)}%\\n+ </>\\n+ ) : (\\n+ <>\\n+ <UploadIcon className=\\\"w-4 h-4\\\" />\\n+ {t(\\\"Choose a file\\\")}\\n+ </>\\n+ )}\\n+ </label>\\n+ <p className=\\\"text-sm text-gray-500 mb-2\\\">\\n+ {t(\\\"or drag and drop here\\\")}\\n+ </p>\\n+ <p className=\\\"text-xs text-gray-400\\\">\\n+ {t(\\\"Supported formats\\\")}:{\\\" \\\"}\\n+ {[\\n+ ...VIDEO_EXTENSIONS,\\n+ ...AUDIO_EXTENSIONS,\\n+ ]\\n+ .map((ext) =>\\n+ ext\\n+ .toUpperCase()\\n+ .replace(\\\".\\\", \\\"\\\"),\\n+ )\\n+ .join(\\\", \\\")}\\n+ <br />\\n+ {t(\\\"Maximum file size\\\")}: 500MB\\n+ </p>\\n+ </div>\\n </div>\\n+ {fileUploadError && (\\n+ <p className=\\\"text-red-500 text-sm\\\">\\n+ {fileUploadError.message}\\n+ </p>\\n+ )}\\n </div>\\n- {fileUploadError && (\\n- <p className=\\\"text-red-500 text-sm\\\">\\n- {fileUploadError.message}\\n- </p>\\n- )}\\n </div>\\n </>\\n )}\\ndiff --git a/src/components/transcribe/VideoPage.js b/src/components/transcribe/VideoPage.js\\nindex a675319..1ed561f 100644\\n--- a/src/components/transcribe/VideoPage.js\\n+++ b/src/components/transcribe/VideoPage.js\\n@@ -21,25 +21,37 @@ import {\\n SelectTrigger,\\n SelectValue,\\n } from \\\"@/components/ui/select\\\";\\n+import { build, parse } from \\\"@aj-archipelago/subvibe\\\";\\n import { useApolloClient } from \\\"@apollo/client\\\";\\n import dayjs from \\\"dayjs\\\";\\n import {\\n+ AlertTriangle,\\n CheckIcon,\\n ChevronDown,\\n CopyIcon,\\n DownloadIcon,\\n+ InfoIcon,\\n MoreVertical,\\n PlusCircleIcon,\\n PlusIcon,\\n RefreshCwIcon,\\n+ TextIcon,\\n TrashIcon,\\n+ VideoIcon,\\n+ Volume2Icon,\\n } from \\\"lucide-react\\\";\\n import { useCallback, useContext, useEffect, useRef, useState } from \\\"react\\\";\\n import { useTranslation } from \\\"react-i18next\\\";\\n-import { FaEdit } from \\\"react-icons/fa\\\";\\n+import { FaEdit, FaYoutube } from \\\"react-icons/fa\\\";\\n import ReactTimeAgo from \\\"react-time-ago\\\";\\n import classNames from \\\"../../../app/utils/class-names\\\";\\n import { AuthContext } from \\\"../../App\\\";\\n+import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import {\\n+ getYoutubeEmbedUrl,\\n+ getYoutubeVideoId,\\n+ isYoutubeUrl,\\n+} from \\\"../../utils/urlUtils\\\";\\n import LoadingButton from \\\"../editor/LoadingButton\\\";\\n import AzureVideoTranslate from \\\"./AzureVideoTranslate\\\";\\n import TranscribeErrorBoundary from \\\"./ErrorBoundary\\\";\\n@@ -48,7 +60,25 @@ import TaxonomySelector from \\\"./TaxonomySelector\\\";\\n import { AddTrackButton } from \\\"./TranscriptionOptions\\\";\\n import TranscriptView from \\\"./TranscriptView\\\";\\n import VideoInput from \\\"./VideoInput\\\";\\n-import { LanguageContext } from \\\"../../contexts/LanguageProvider\\\";\\n+import { useAutoTranscribe } from \\\"../../contexts/AutoTranscribeContext\\\";\\n+import { QUERIES } from \\\"../../graphql\\\";\\n+import { useProgress } from \\\"../../contexts/ProgressContext\\\";\\n+import Loader from \\\"../../../app/components/loader\\\";\\n+import { isAudioUrl } from \\\"../../utils/mediaUtils\\\";\\n+\\n+// Add getTranscribeQuery function\\n+const getTranscribeQuery = (modelOption) => {\\n+ switch (modelOption) {\\n+ case \\\"Whisper\\\":\\n+ return QUERIES.TRANSCRIBE;\\n+ case \\\"NeuralSpace\\\":\\n+ return QUERIES.TRANSCRIBE_NEURALSPACE;\\n+ case \\\"Gemini\\\":\\n+ return QUERIES.TRANSCRIBE_GEMINI;\\n+ default:\\n+ return QUERIES.TRANSCRIBE;\\n+ }\\n+};\\n \\n const isValidUrl = (url) => {\\n try {\\n@@ -59,79 +89,17 @@ const isValidUrl = (url) => {\\n }\\n };\\n \\n-// New TaxonomyDialog component\\n-function TaxonomyDialog({ text }) {\\n- const { t } = useTranslation();\\n-\\n- return (\\n- <Dialog>\\n- <DialogTrigger className=\\\"lb-outline-secondary flex items-center gap-1 text-xs\\\">\\n- {t(\\\"Hashtags and Topics\\\")}\\n- </DialogTrigger>\\n- <DialogContent className=\\\"max-w-3xl max-h-[80vh] overflow-y-auto\\\">\\n- <DialogHeader>\\n- <DialogTitle>{t(\\\"Select Taxonomy\\\")}</DialogTitle>\\n- </DialogHeader>\\n- <TaxonomySelector text={text} />\\n- </DialogContent>\\n- </Dialog>\\n- );\\n-}\\n-\\n // New DownloadButton component\\n function DownloadButton({ format, name, text }) {\\n const { t } = useTranslation();\\n \\n- const convertVttToSrt = (vttText) => {\\n- const lines = vttText.split(\\\"\\\\n\\\");\\n- let srtContent = \\\"\\\";\\n- let subtitleCount = 1;\\n- let currentSubtitle = {};\\n- let isTextContent = false;\\n-\\n- lines.forEach((line) => {\\n- const timestampMatch = line.match(\\n- /(\\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{3}) --> (\\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{3})/,\\n- );\\n- if (timestampMatch) {\\n- if (currentSubtitle.timestamp) {\\n- srtContent += `${subtitleCount}\\\\n${currentSubtitle.timestamp}\\\\n${currentSubtitle.text.trim()}\\\\n\\\\n`;\\n- subtitleCount++;\\n- }\\n- // Convert timestamp format from VTT to SRT (replace . with ,)\\n- currentSubtitle = {\\n- timestamp: `${timestampMatch[1].replace(\\\".\\\", \\\",\\\")} --> ${timestampMatch[2].replace(\\\".\\\", \\\",\\\")}`,\\n- text: \\\"\\\",\\n- };\\n- isTextContent = true;\\n- } else if (\\n- line.trim() &&\\n- isTextContent &&\\n- currentSubtitle.timestamp\\n- ) {\\n- const trimmedLine = line.trim();\\n- currentSubtitle.text = currentSubtitle.text\\n- ? `${currentSubtitle.text}\\\\n${trimmedLine}`\\n- : trimmedLine;\\n- } else if (!line.trim()) {\\n- isTextContent = false;\\n- }\\n- });\\n-\\n- // Add the last subtitle\\n- if (currentSubtitle.timestamp) {\\n- srtContent += `${subtitleCount}\\\\n${currentSubtitle.timestamp}\\\\n${currentSubtitle.text.trim()}\\\\n\\\\n`;\\n- }\\n-\\n- return srtContent.trim();\\n- };\\n-\\n const downloadFile = (selectedFormat) => {\\n let downloadText = text;\\n \\n // Convert format if needed\\n if (selectedFormat === \\\"srt\\\") {\\n- downloadText = convertVttToSrt(text);\\n+ const parsed = parse(downloadText);\\n+ downloadText = build(parsed.cues, \\\"srt\\\");\\n }\\n \\n const element = document.createElement(\\\"a\\\");\\n@@ -153,7 +121,7 @@ function DownloadButton({ format, name, text }) {\\n \\n return (\\n <DropdownMenu>\\n- <DropdownMenuTrigger className=\\\"lb-outline-secondary flex items-center gap-1 text-xs\\\">\\n+ <DropdownMenuTrigger className=\\\"hidden sm:flex lb-outline-secondary items-center gap-1 text-xs\\\">\\n <div className=\\\"flex items-center gap-2 pe-1\\\">\\n <DownloadIcon className=\\\"h-4 w-4\\\" />\\n {t(\\\"Download\\\")}\\n@@ -213,6 +181,8 @@ function EditableTranscriptSelect({\\n const { t } = useTranslation();\\n const [editing, setEditing] = useState(false);\\n const [tempName, setTempName] = useState(\\\"\\\");\\n+ const { isAutoTranscribing } = useAutoTranscribe();\\n+ const [taxonomyDialogOpen, setTaxonomyDialogOpen] = useState(false);\\n \\n useEffect(() => {\\n if (transcripts[activeTranscript]) {\\n@@ -229,6 +199,16 @@ function EditableTranscriptSelect({\\n onNameChange(tempName);\\n };\\n \\n+ useEffect(() => {\\n+ if (\\n+ transcripts &&\\n+ transcripts.length > 0 &&\\n+ !transcripts[activeTranscript]\\n+ ) {\\n+ setActiveTranscript(0);\\n+ }\\n+ }, [activeTranscript, transcripts, setActiveTranscript]);\\n+\\n const handleCancel = () => {\\n setEditing(false);\\n if (transcripts[activeTranscript]) {\\n@@ -240,35 +220,51 @@ function EditableTranscriptSelect({\\n };\\n \\n if (!transcripts.length) {\\n- // return add track button\\n+ // Show transcribing message if auto-transcription is in progress\\n+\\n+ // Otherwise return add track button\\n return (\\n- <AddTrackButton\\n- transcripts={transcripts}\\n- url={url}\\n- onAdd={onAdd}\\n- activeTranscript={activeTranscript}\\n- trigger={\\n- <button className=\\\"lb-outline-secondary flex items-center gap-1 text-xs\\\">\\n- {t(\\\"Add subtitles or transcript\\\")}\\n- </button>\\n- }\\n- apolloClient={apolloClient}\\n- addTrackDialogOpen={addTrackDialogOpen}\\n- setAddTrackDialogOpen={setAddTrackDialogOpen}\\n- selectedTab={selectedTab}\\n- setSelectedTab={setSelectedTab}\\n- />\\n+ <>\\n+ <AddTrackButton\\n+ transcripts={transcripts}\\n+ url={url}\\n+ onAdd={onAdd}\\n+ activeTranscript={activeTranscript}\\n+ trigger={\\n+ <button className=\\\"lb-primary flex items-center gap-1 \\\">\\n+ {t(\\\"Add subtitles or transcript\\\")}\\n+ </button>\\n+ }\\n+ apolloClient={apolloClient}\\n+ addTrackDialogOpen={addTrackDialogOpen}\\n+ setAddTrackDialogOpen={setAddTrackDialogOpen}\\n+ selectedTab={selectedTab}\\n+ setSelectedTab={setSelectedTab}\\n+ />\\n+ {isAutoTranscribing && (\\n+ <div className=\\\"mt-2 flex gap-3 items-center py-2 px-3 rounded-lg border bg-gray-50 w-[500px]\\\">\\n+ <Loader size=\\\"default\\\" />\\n+ <div className=\\\"text-gray-700 text-sm\\\">\\n+ {t(\\\"Transcribing... This may take a few minutes.\\\")}\\n+ </div>\\n+ </div>\\n+ )}\\n+ </>\\n );\\n }\\n \\n return (\\n- <div className=\\\"mb-2\\\">\\n+ <div className=\\\"\\\">\\n+ <div className=\\\"flex gap-2 items-center text-sky-600 font-semibold mb-2\\\">\\n+ <TextIcon className=\\\"h-4 w-4\\\" />\\n+ <div className=\\\"text-sm\\\">{t(\\\"Subtitles and transcripts\\\")}</div>\\n+ </div>\\n {editing ? (\\n <div className=\\\"flex gap-2\\\">\\n <input\\n autoFocus\\n type=\\\"text\\\"\\n- className=\\\"text-md w-[300px] font-medium rounded-md py-1.5 my-[1px] px-3 border border-gray-300\\\"\\n+ className=\\\"w-[300px] text-sm font-medium rounded-md py-1 my-[1px] px-3 border border-gray-300\\\"\\n value={tempName}\\n onChange={(e) => setTempName(e.target.value)}\\n onKeyDown={(e) => {\\n@@ -298,130 +294,349 @@ function EditableTranscriptSelect({\\n </div>\\n </div>\\n ) : (\\n- <div className=\\\"flex flex-col md:flex-row gap-2 justify-between \\\">\\n- <div className=\\\"flex gap-2 grow\\\">\\n- <Select\\n- value={activeTranscript.toString()}\\n- onValueChange={(value) =>\\n- setActiveTranscript(parseInt(value))\\n- }\\n- >\\n- <SelectTrigger className=\\\"w-[300px] py-1 text-md font-medium text-start\\\">\\n- <SelectValue>\\n- {transcripts[activeTranscript]?.name ||\\n- `Transcript ${activeTranscript + 1}`}\\n- </SelectValue>\\n- </SelectTrigger>\\n- <SelectContent className=\\\"overflow-hidden\\\">\\n- {transcripts.map((transcript, index) => (\\n- <SelectItem\\n- className=\\\"w-[500px] border-b last:border-b-0 border-gray-100\\\"\\n- key={index}\\n- value={index.toString()}\\n- >\\n- <div className=\\\"flex flex-col py-2 w-full\\\">\\n- <div className=\\\"flex w-full items-center justify-between gap-4\\\">\\n- <div className=\\\"grow \\\">\\n- {transcript.name ||\\n- `Transcript ${index + 1}`}\\n- </div>\\n- <span\\n- className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md \\n- ${transcripts[index].format === \\\"vtt\\\" ? \\\"bg-green-100 text-green-800\\\" : \\\"bg-orange-100 text-orange-800\\\"}`}\\n- >\\n- {transcripts[index]\\n- .format === \\\"vtt\\\"\\n- ? t(\\\"Subtitles\\\")\\n- : t(\\\"Transcript\\\")}\\n- </span>\\n- </div>\\n-\\n- <div className=\\\"flex items-center gap-2 justify-between\\\">\\n- <div className=\\\"text-xs text-gray-400 flex items-center\\\">\\n- <ReactTimeAgo\\n- date={\\n- transcript.timestamp\\n- ? new Date(\\n- transcript.timestamp,\\n- ).getTime()\\n- : Date.now()\\n- }\\n- locale=\\\"en-US\\\"\\n- />\\n- </div>\\n- </div>\\n- </div>\\n- </SelectItem>\\n- ))}\\n- </SelectContent>\\n- </Select>\\n+ <>\\n+ <div className=\\\"flex flex-row sm:flex-col-reverse md:flex-row gap-2 justify-between \\\">\\n <div className=\\\"flex gap-2 items-center\\\">\\n- <AddTrackButton\\n- transcripts={transcripts}\\n- url={url}\\n- onAdd={onAdd}\\n- activeTranscript={activeTranscript}\\n- trigger={\\n- <button className=\\\"flex items-center text-sky-600 hover:text-sky-700\\\">\\n- <PlusCircleIcon className=\\\"h-6 w-6\\\" />{\\\" \\\"}\\n- {transcripts.length > 0\\n- ? t(\\\"\\\")\\n- : t(\\\"Add subtitles or transcript\\\")}\\n- </button>\\n- }\\n- apolloClient={apolloClient}\\n- addTrackDialogOpen={addTrackDialogOpen}\\n- setAddTrackDialogOpen={setAddTrackDialogOpen}\\n- selectedTab={selectedTab}\\n- setSelectedTab={setSelectedTab}\\n- />\\n- </div>\\n- </div>\\n- {!isEditing && transcripts[activeTranscript] && (\\n- <div className=\\\"flex items-center gap-2 justify-end\\\">\\n- {transcripts[activeTranscript].format !== \\\"vtt\\\" && (\\n- <button\\n- onClick={() => setIsEditing(!isEditing)}\\n- className=\\\"lb-outline-secondary flex items-center gap-1 text-xs\\\"\\n- title={t(\\\"Edit\\\")}\\n+ <div className=\\\"grow sm:grow-0 \\\">\\n+ <Select\\n+ value={activeTranscript.toString()}\\n+ onValueChange={(value) =>\\n+ setActiveTranscript(parseInt(value))\\n+ }\\n >\\n- {t(\\\"Edit\\\")}\\n- </button>\\n- )}\\n- <TaxonomyDialog\\n- text={transcripts[activeTranscript].text}\\n- />\\n- <DownloadButton\\n- format={transcripts[activeTranscript].format}\\n- name={transcripts[activeTranscript].name}\\n- text={transcripts[activeTranscript].text}\\n- />\\n- <DropdownMenu>\\n- <DropdownMenuTrigger className=\\\"\\\">\\n- <MoreVertical className=\\\"h-4 w-4\\\" />\\n- </DropdownMenuTrigger>\\n- <DropdownMenuContent>\\n- <DropdownMenuItem\\n- className=\\\"text-red-600 focus:text-red-600 focus:bg-red-50 text-xs\\\"\\n- onClick={() => {\\n- if (\\n- window.confirm(\\n- t(\\n- \\\"Are you sure you want to delete this track?\\\",\\n- ),\\n- )\\n- ) {\\n- onDeleteTrack();\\n+ <SelectTrigger className=\\\"w-full sm:w-[300px] py-1 h-8 font-medium text-start\\\">\\n+ <SelectValue>\\n+ {transcripts[activeTranscript]\\n+ ?.name ||\\n+ `Transcript ${activeTranscript + 1}`}\\n+ </SelectValue>\\n+ </SelectTrigger>\\n+ <SelectContent className=\\\"overflow-hidden\\\">\\n+ {transcripts.map(\\n+ (transcript, index) => (\\n+ <SelectItem\\n+ className=\\\"w-[500px] border-b last:border-b-0 border-gray-100\\\"\\n+ key={index}\\n+ value={index.toString()}\\n+ >\\n+ <div className=\\\"flex flex-col py-2 w-full\\\">\\n+ <div className=\\\"flex w-full items-center justify-between gap-4\\\">\\n+ <div className=\\\"grow \\\">\\n+ {transcript.name ||\\n+ `Transcript ${index + 1}`}\\n+ </div>\\n+ <span\\n+ className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-md \\n+ ${transcripts[index].format === \\\"vtt\\\" ? \\\"bg-green-100 text-green-800\\\" : \\\"bg-orange-100 text-orange-800\\\"}`}\\n+ >\\n+ {transcripts[\\n+ index\\n+ ].format ===\\n+ \\\"vtt\\\"\\n+ ? t(\\n+ \\\"Subtitles\\\",\\n+ )\\n+ : t(\\n+ \\\"Transcript\\\",\\n+ )}\\n+ </span>\\n+ </div>\\n+\\n+ <div className=\\\"flex items-center gap-2 justify-between\\\">\\n+ <div className=\\\"text-xs text-gray-400 flex items-center\\\">\\n+ <ReactTimeAgo\\n+ date={\\n+ transcript.timestamp\\n+ ? new Date(\\n+ transcript.timestamp,\\n+ ).getTime()\\n+ : Date.now()\\n+ }\\n+ locale=\\\"en-US\\\"\\n+ />\\n+ </div>\\n+ </div>\\n+ </div>\\n+ </SelectItem>\\n+ ),\\n+ )}\\n+ </SelectContent>\\n+ </Select>\\n+ </div>\\n+ <div className=\\\"flex gap-2 items-center\\\">\\n+ <AddTrackButton\\n+ transcripts={transcripts}\\n+ url={url}\\n+ onAdd={onAdd}\\n+ activeTranscript={activeTranscript}\\n+ trigger={\\n+ <button className=\\\"flex items-center text-sky-600 hover:text-sky-700\\\">\\n+ <PlusCircleIcon className=\\\"h-5 w-5\\\" />\\n+ </button>\\n+ }\\n+ apolloClient={apolloClient}\\n+ addTrackDialogOpen={addTrackDialogOpen}\\n+ setAddTrackDialogOpen={\\n+ setAddTrackDialogOpen\\n+ }\\n+ selectedTab={selectedTab}\\n+ setSelectedTab={setSelectedTab}\\n+ />\\n+ </div>\\n+ </div>\\n+ {!isEditing && transcripts[activeTranscript] && (\\n+ <div className=\\\"flex flex-col sm:flex-row items-center gap-2 justify-end\\\">\\n+ <div className=\\\" w-full sm:w-auto flex gap-2 justify-end\\\">\\n+ {transcripts[activeTranscript].format !==\\n+ \\\"vtt\\\" && (\\n+ <button\\n+ onClick={() =>\\n+ setIsEditing(!isEditing)\\n }\\n- }}\\n+ className=\\\"hidden sm:flex lb-outline-secondary items-center gap-1 text-xs\\\"\\n+ title={t(\\\"Edit\\\")}\\n+ >\\n+ {t(\\\"Edit\\\")}\\n+ </button>\\n+ )}\\n+ {/* Show the button only on desktop */}\\n+ <Dialog\\n+ open={taxonomyDialogOpen}\\n+ onOpenChange={setTaxonomyDialogOpen}\\n >\\n- {t(\\\"Delete track\\\")}\\n- </DropdownMenuItem>\\n- </DropdownMenuContent>\\n- </DropdownMenu>\\n- </div>\\n- )}\\n- </div>\\n+ <DialogTrigger asChild>\\n+ <button\\n+ className=\\\"hidden sm:flex lb-outline-secondary items-center gap-1 text-xs overflow-hidden\\\"\\n+ title={t(\\\"Hashtags and Topics\\\")}\\n+ >\\n+ <div className=\\\"truncate w-full\\\">\\n+ {t(\\\"Hashtags and Topics\\\")}\\n+ </div>\\n+ </button>\\n+ </DialogTrigger>\\n+ <DialogContent className=\\\"max-w-3xl max-h-[80vh] overflow-y-auto\\\">\\n+ <DialogHeader>\\n+ <DialogTitle>\\n+ {t(\\\"Select Taxonomy\\\")}\\n+ </DialogTitle>\\n+ </DialogHeader>\\n+ <TaxonomySelector\\n+ text={\\n+ transcripts[\\n+ activeTranscript\\n+ ].text\\n+ }\\n+ />\\n+ </DialogContent>\\n+ </Dialog>\\n+ </div>\\n+ <div className=\\\"w-full sm:w-auto flex gap-2 justify-end\\\">\\n+ <DownloadButton\\n+ format={\\n+ transcripts[activeTranscript].format\\n+ }\\n+ name={\\n+ transcripts[activeTranscript].name\\n+ }\\n+ text={\\n+ transcripts[activeTranscript].text\\n+ }\\n+ />\\n+ <DropdownMenu>\\n+ <DropdownMenuTrigger className=\\\"\\\">\\n+ <MoreVertical className=\\\"h-4 w-4\\\" />\\n+ </DropdownMenuTrigger>\\n+ <DropdownMenuContent>\\n+ {transcripts[activeTranscript]\\n+ .format !== \\\"vtt\\\" && (\\n+ <DropdownMenuItem\\n+ className=\\\"text-xs\\\"\\n+ onClick={() =>\\n+ setIsEditing(!isEditing)\\n+ }\\n+ >\\n+ {t(\\\"Edit\\\")}\\n+ </DropdownMenuItem>\\n+ )}\\n+ {/* Show the menu item only on mobile */}\\n+ <DropdownMenuItem\\n+ className=\\\"sm:hidden text-xs\\\"\\n+ onClick={() => {\\n+ setTimeout(() => {\\n+ setTaxonomyDialogOpen(\\n+ true,\\n+ );\\n+ }, 100);\\n+ }}\\n+ >\\n+ {t(\\\"Hashtags and Topics\\\")}\\n+ </DropdownMenuItem>\\n+ {/* Download options for mobile */}\\n+ {transcripts[activeTranscript]\\n+ .format === \\\"srt\\\" ||\\n+ transcripts[activeTranscript]\\n+ .format === \\\"vtt\\\" ? (\\n+ <>\\n+ <DropdownMenuItem\\n+ className=\\\"sm:hidden text-xs\\\"\\n+ onClick={() => {\\n+ let downloadText =\\n+ transcripts[\\n+ activeTranscript\\n+ ].text;\\n+ const parsed =\\n+ parse(\\n+ downloadText,\\n+ );\\n+ downloadText =\\n+ build(\\n+ parsed.cues,\\n+ \\\"srt\\\",\\n+ );\\n+\\n+ const element =\\n+ document.createElement(\\n+ \\\"a\\\",\\n+ );\\n+ const file =\\n+ new Blob(\\n+ [\\n+ downloadText,\\n+ ],\\n+ {\\n+ type: \\\"text/plain\\\",\\n+ },\\n+ );\\n+ element.href =\\n+ URL.createObjectURL(\\n+ file,\\n+ );\\n+ element.download = `${transcripts[activeTranscript].name}.srt`;\\n+ element.style.display =\\n+ \\\"none\\\";\\n+ document.body.appendChild(\\n+ element,\\n+ );\\n+ element.click();\\n+ setTimeout(() => {\\n+ document.body.removeChild(\\n+ element,\\n+ );\\n+ URL.revokeObjectURL(\\n+ element.href,\\n+ );\\n+ }, 100);\\n+ }}\\n+ >\\n+ {t(\\\"Download SRT\\\")}\\n+ </DropdownMenuItem>\\n+ <DropdownMenuItem\\n+ className=\\\"sm:hidden text-xs\\\"\\n+ onClick={() => {\\n+ const element =\\n+ document.createElement(\\n+ \\\"a\\\",\\n+ );\\n+ const file =\\n+ new Blob(\\n+ [\\n+ transcripts[\\n+ activeTranscript\\n+ ].text,\\n+ ],\\n+ {\\n+ type: \\\"text/plain\\\",\\n+ },\\n+ );\\n+ element.href =\\n+ URL.createObjectURL(\\n+ file,\\n+ );\\n+ element.download = `${transcripts[activeTranscript].name}.vtt`;\\n+ element.style.display =\\n+ \\\"none\\\";\\n+ document.body.appendChild(\\n+ element,\\n+ );\\n+ element.click();\\n+ setTimeout(() => {\\n+ document.body.removeChild(\\n+ element,\\n+ );\\n+ URL.revokeObjectURL(\\n+ element.href,\\n+ );\\n+ }, 100);\\n+ }}\\n+ >\\n+ {t(\\\"Download VTT\\\")}\\n+ </DropdownMenuItem>\\n+ </>\\n+ ) : (\\n+ <DropdownMenuItem\\n+ className=\\\"sm:hidden text-xs\\\"\\n+ onClick={() => {\\n+ const element =\\n+ document.createElement(\\n+ \\\"a\\\",\\n+ );\\n+ const file = new Blob(\\n+ [\\n+ transcripts[\\n+ activeTranscript\\n+ ].text,\\n+ ],\\n+ {\\n+ type: \\\"text/plain\\\",\\n+ },\\n+ );\\n+ element.href =\\n+ URL.createObjectURL(\\n+ file,\\n+ );\\n+ element.download = `${transcripts[activeTranscript].name}.txt`;\\n+ element.style.display =\\n+ \\\"none\\\";\\n+ document.body.appendChild(\\n+ element,\\n+ );\\n+ element.click();\\n+ setTimeout(() => {\\n+ document.body.removeChild(\\n+ element,\\n+ );\\n+ URL.revokeObjectURL(\\n+ element.href,\\n+ );\\n+ }, 100);\\n+ }}\\n+ >\\n+ {t(\\\"Download .txt\\\")}\\n+ </DropdownMenuItem>\\n+ )}\\n+ <DropdownMenuItem\\n+ className=\\\"text-red-600 focus:text-red-600 focus:bg-red-50 text-xs\\\"\\n+ onClick={() => {\\n+ if (\\n+ window.confirm(\\n+ t(\\n+ \\\"Are you sure you want to delete this track?\\\",\\n+ ),\\n+ )\\n+ ) {\\n+ onDeleteTrack();\\n+ }\\n+ }}\\n+ >\\n+ {t(\\\"Delete track\\\")}\\n+ </DropdownMenuItem>\\n+ </DropdownMenuContent>\\n+ </DropdownMenu>\\n+ </div>\\n+ </div>\\n+ )}\\n+ </div>\\n+ </>\\n )}\\n {transcripts[activeTranscript]?.timestamp && (\\n <div className=\\\"flex items-center text-gray-400 text-xs py-1 px-3\\\">\\n@@ -446,62 +661,323 @@ function EditableTranscriptSelect({\\n \\n function VideoPlayer({\\n videoLanguages,\\n+ setYoutubePlayer,\\n activeLanguage,\\n- transcripts,\\n- activeTranscript,\\n onTimeUpdate,\\n- videoInformation,\\n vttUrl,\\n+ videoInformation,\\n+ copied,\\n+ handleCopy,\\n }) {\\n- const [isAudioOnly, setIsAudioOnly] = useState(false);\\n+ const [isAudioOnly, setIsAudioOnly] = useState(\\n+ videoLanguages[activeLanguage]?.url?.includes(\\\".mp3\\\"),\\n+ );\\n const videoRef = useRef(null);\\n+ const videoUrl =\\n+ videoLanguages[activeLanguage]?.url || videoInformation?.videoUrl;\\n+ const isYouTube = isYoutubeUrl(videoUrl);\\n+ const embedUrl = isYouTube ? getYoutubeEmbedUrl(videoUrl) : videoUrl;\\n+ const [videoError, setVideoError] = useState(false);\\n+ const { t } = useTranslation();\\n+\\n+ useEffect(() => {\\n+ if (!videoUrl || !isYouTube) return;\\n+\\n+ // Reset error state when URL changes\\n+ setVideoError(false);\\n+\\n+ // Check if script already exists\\n+ if (!document.getElementById(\\\"youtube-iframe-api\\\")) {\\n+ const tag = document.createElement(\\\"script\\\");\\n+ tag.id = \\\"youtube-iframe-api\\\";\\n+ tag.src = \\\"https://www.youtube.com/iframe_api\\\";\\n+ const firstScriptTag = document.getElementsByTagName(\\\"script\\\")[0];\\n+ firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);\\n+ }\\n+\\n+ // Poll for YT API to be ready\\n+ const maxAttempts = 40; // 10 seconds total (40 * 250ms)\\n+ let attempts = 0;\\n+\\n+ const initializePlayer = () => {\\n+ new window.YT.Player(\\\"ytplayer\\\", {\\n+ videoId: getYoutubeVideoId(videoUrl),\\n+ width: 800,\\n+ height: 450,\\n+ events: {\\n+ onReady: (event) => {\\n+ setYoutubePlayer(event.target);\\n+ },\\n+ onError: () => {\\n+ setVideoError(true);\\n+ },\\n+ },\\n+ });\\n+ };\\n+\\n+ const pollForYT = setInterval(() => {\\n+ if (window.YT && window.YT.loaded) {\\n+ clearInterval(pollForYT);\\n+ initializePlayer();\\n+ } else if (attempts >= maxAttempts) {\\n+ clearInterval(pollForYT);\\n+ console.error(\\\"Timeout waiting for YouTube IFrame API to load\\\");\\n+ }\\n+ attempts++;\\n+ }, 250);\\n+\\n+ // Cleanup\\n+ return () => {\\n+ clearInterval(pollForYT);\\n+ setYoutubePlayer(null);\\n+ };\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n+ }, [videoUrl, isYouTube]);\\n \\n const handleVideoReady = () => {\\n- if (videoRef.current && videoRef.current.videoHeight === 0) {\\n+ if (\\n+ !isYouTube &&\\n+ videoRef.current &&\\n+ videoRef.current.videoHeight === 0\\n+ ) {\\n setIsAudioOnly(true);\\n } else {\\n setIsAudioOnly(false);\\n }\\n };\\n \\n+ const handleVideoError = () => {\\n+ setVideoError(true);\\n+ };\\n+\\n+ // Reset error state when URL changes\\n+ useEffect(() => {\\n+ setVideoError(false);\\n+ }, [videoUrl]);\\n+\\n return (\\n- <>\\n+ <div className=\\\"flex flex-col gap-1\\\">\\n <div\\n className={classNames(\\n- \\\"rounded-lg flex justify-center items-center border border-gray-200/50\\\",\\n- isAudioOnly\\n- ? \\\"h-[50px] w-96\\\"\\n- : \\\"grow max-h-[40vh] bg-[#000]\\\",\\n+ \\\"rounded-lg flex justify-center items-center overflow-hidden\\\",\\n+ isAudioOnly ? \\\"h-[50px] w-96\\\" : \\\"w-full bg-[#000] border\\\",\\n )}\\n >\\n- <video\\n- className={`rounded-lg ${isAudioOnly ? \\\"h-[50px] w-96\\\" : \\\"grow max-h-[40vh]\\\"}`}\\n- ref={videoRef}\\n- src={videoLanguages[activeLanguage]?.url}\\n- controls\\n- onLoadedData={handleVideoReady}\\n- onTimeUpdate={() =>\\n- onTimeUpdate(videoRef.current?.currentTime)\\n- }\\n- controlsList=\\\"nodownload\\\"\\n- >\\n- {vttUrl && (\\n- <track\\n- kind=\\\"subtitles\\\"\\n- src={vttUrl}\\n- srcLang=\\\"en\\\"\\n- label=\\\"English\\\"\\n- default\\n+ {videoError ? (\\n+ <div className=\\\"w-full p-6 bg-gray-50\\\">\\n+ <div className=\\\"text-gray-600 font-medium mb-2 flex items-center gap-2\\\">\\n+ <AlertTriangle className=\\\"h-4 w-4\\\" />\\n+ {t(\\\"Video Unavailable\\\")}\\n+ </div>\\n+ <p className=\\\"text-sm text-gray-500\\\">\\n+ {t(\\n+ \\\"The video URL cannot be accessed. It may have expired or been deleted.\\\",\\n+ )}\\n+ </p>\\n+ </div>\\n+ ) : isYouTube ? (\\n+ <div className=\\\"w-full relative h-[40vh] max-h-[40vh]\\\">\\n+ <div\\n+ id=\\\"ytplayer\\\"\\n+ className=\\\"rounded-lg aspect-video mx-auto max-w-full h-full\\\"\\n+ src={embedUrl}\\n+ allowFullScreen\\n+ title=\\\"YouTube video player\\\"\\n+ allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\\\"\\n />\\n- )}\\n- </video>\\n+ </div>\\n+ ) : (\\n+ <video\\n+ className={`rounded-lg ${isAudioOnly ? \\\"h-[50px] w-96\\\" : \\\"w-full max-h-[40vh]\\\"}`}\\n+ ref={videoRef}\\n+ src={videoUrl}\\n+ controls\\n+ onLoadedData={handleVideoReady}\\n+ onError={handleVideoError}\\n+ onTimeUpdate={() =>\\n+ onTimeUpdate(videoRef.current?.currentTime)\\n+ }\\n+ controlsList=\\\"nodownload\\\"\\n+ >\\n+ {vttUrl && (\\n+ <track\\n+ kind=\\\"subtitles\\\"\\n+ src={vttUrl}\\n+ srcLang=\\\"en\\\"\\n+ label=\\\"English\\\"\\n+ default\\n+ />\\n+ )}\\n+ </video>\\n+ )}\\n+ </div>\\n+\\n+ <div className=\\\"\\\">\\n+ {/* Note message removed as we're now properly handling YouTube vs video vs audio files */}\\n </div>\\n- </>\\n+ <VideoInformationBox\\n+ videoInformation={videoInformation}\\n+ videoLanguages={videoLanguages}\\n+ activeLanguage={activeLanguage}\\n+ copied={copied}\\n+ handleCopy={handleCopy}\\n+ />\\n+ </div>\\n+ );\\n+}\\n+\\n+// New component for video information\\n+function VideoInformationBox({\\n+ videoInformation,\\n+ videoLanguages,\\n+ activeLanguage,\\n+ copied,\\n+ handleCopy,\\n+}) {\\n+ const { t } = useTranslation();\\n+ const currentUrl =\\n+ videoLanguages[activeLanguage]?.url || videoInformation?.videoUrl;\\n+ const [isExpanded, setIsExpanded] = useState(false);\\n+ const [mediaInfo, setMediaInfo] = useState(null);\\n+\\n+ useEffect(() => {\\n+ // For YouTube videos, we don't need to check the media type\\n+ if (isYoutubeUrl(currentUrl)) {\\n+ setMediaInfo({\\n+ type: \\\"youtube\\\",\\n+ icon: <FaYoutube className=\\\"h-4 w-4 text-red-500\\\" />,\\n+ label: t(\\\"YouTube Video\\\"),\\n+ });\\n+ return;\\n+ }\\n+\\n+ // For other media, create a temporary media element to check type\\n+ const isAudioURL =\\n+ currentUrl?.toLowerCase().includes(\\\"audio\\\") ||\\n+ currentUrl?.toLowerCase().includes(\\\".mp3\\\");\\n+ const element = isAudioURL\\n+ ? new Audio()\\n+ : document.createElement(\\\"video\\\");\\n+\\n+ element.onloadedmetadata = () => {\\n+ // Try to get MIME type from the currentSrc\\n+ let mimeType = \\\"\\\";\\n+ try {\\n+ const contentType = element.currentSrc\\n+ .split(\\\";\\\")[0]\\n+ .split(\\\"/\\\")\\n+ .pop();\\n+ // Clean up the MIME type - remove query parameters and decode URL\\n+ mimeType = contentType.split(\\\"?\\\")[0].split(\\\"#\\\")[0];\\n+ // Handle encoded URLs\\n+ mimeType = decodeURIComponent(mimeType);\\n+ // Extract just the extension if it's a filename\\n+ if (mimeType.includes(\\\".\\\")) {\\n+ mimeType = mimeType.split(\\\".\\\").pop();\\n+ }\\n+ mimeType = mimeType.toUpperCase();\\n+ } catch (error) {\\n+ console.error(\\\"Error extracting MIME type:\\\", error);\\n+ mimeType = isAudioURL ? \\\"MP3\\\" : \\\"MP4\\\";\\n+ }\\n+\\n+ if (element instanceof HTMLAudioElement || isAudioURL) {\\n+ setMediaInfo({\\n+ type: \\\"audio\\\",\\n+ icon: <Volume2Icon className=\\\"h-4 w-4\\\" />,\\n+ label: t(\\\"Audio File\\\"),\\n+ extension: mimeType || \\\"MP3\\\",\\n+ });\\n+ } else {\\n+ setMediaInfo({\\n+ type: \\\"video\\\",\\n+ icon: <VideoIcon className=\\\"h-4 w-4\\\" />,\\n+ label: t(\\\"Video File\\\"),\\n+ extension: mimeType || \\\"MP4\\\",\\n+ });\\n+ }\\n+ };\\n+\\n+ element.onerror = () => {\\n+ // Fallback if we can't determine the type\\n+ setMediaInfo({\\n+ type: \\\"unknown\\\",\\n+ icon: <VideoIcon className=\\\"h-4 w-4\\\" />,\\n+ label: t(\\\"Media File\\\"),\\n+ extension: t(\\\"Unknown\\\"),\\n+ });\\n+ };\\n+\\n+ // Try to load just the metadata\\n+ element.preload = \\\"metadata\\\";\\n+ element.src = currentUrl;\\n+\\n+ return () => {\\n+ element.src = \\\"\\\";\\n+ element.remove();\\n+ };\\n+ }, [currentUrl, t]);\\n+\\n+ return (\\n+ <div className=\\\"p-2 border border-gray-200/50 rounded-lg\\\">\\n+ <button\\n+ onClick={() => setIsExpanded(!isExpanded)}\\n+ className={classNames(\\n+ \\\"w-full flex items-center justify-between text-xs font-medium text-gray-500 hover:text-sky-700\\\",\\n+ isExpanded ? \\\"mb-4\\\" : \\\"\\\",\\n+ )}\\n+ >\\n+ <div className=\\\"flex items-center gap-1.5\\\">\\n+ <InfoIcon className=\\\"h-4 w-4\\\" />\\n+ {t(\\\"File Information\\\")}\\n+ </div>\\n+ <ChevronDown\\n+ className={`h-4 w-4 transition-transform ${isExpanded ? \\\"rotate-180\\\" : \\\"\\\"}`}\\n+ />\\n+ </button>\\n+ {isExpanded && mediaInfo && (\\n+ <div className=\\\"space-y-2\\\">\\n+ <div className=\\\"grid grid-cols-[40px_1fr] gap-2 items-center\\\">\\n+ <div className=\\\"text-xs text-gray-500\\\">{t(\\\"Type\\\")}</div>\\n+ <div className=\\\"flex items-center gap-1 text-xs text-gray-600\\\">\\n+ {mediaInfo.icon}\\n+ <span>{mediaInfo.label}</span>\\n+ {mediaInfo.extension && (\\n+ <span className=\\\"px-1.5 py-0.5 bg-gray-100 rounded text-[10px] font-medium\\\">\\n+ {mediaInfo.extension}\\n+ </span>\\n+ )}\\n+ </div>\\n+ </div>\\n+ <div className=\\\"grid grid-cols-[40px_1fr] gap-2 items-center\\\">\\n+ <div className=\\\"text-xs text-gray-500\\\">{t(\\\"URL\\\")}</div>\\n+ <div className=\\\"w-full flex gap-2 overflow-hidden items-center py-1 px-2 rounded-md bg-gray-100\\\">\\n+ <div className=\\\"text-xs text-gray-600 truncate grow\\\">\\n+ {currentUrl}\\n+ </div>\\n+ <button\\n+ onClick={() => handleCopy(currentUrl)}\\n+ className=\\\"p-1 hover:bg-gray-100 rounded transition-colors flex-shrink-0\\\"\\n+ title={t(\\\"Copy URL\\\")}\\n+ >\\n+ {copied ? (\\n+ <CheckIcon className=\\\"h-4 w-4 text-green-500\\\" />\\n+ ) : (\\n+ <CopyIcon className=\\\"h-4 w-4 text-gray-500\\\" />\\n+ )}\\n+ </button>\\n+ </div>\\n+ </div>\\n+ </div>\\n+ )}\\n+ </div>\\n );\\n }\\n \\n function VideoPage() {\\n const [transcripts, setTranscripts] = useState([]);\\n+ const transcriptsRef = useRef(transcripts);\\n+ const videoInformationRef = useRef(null);\\n const [activeTranscript, setActiveTranscript] = useState(0);\\n const [url, setUrl] = useState(\\\"\\\");\\n const [videoInformation, setVideoInformation] = useState();\\n@@ -509,6 +985,8 @@ function VideoPage() {\\n const { t } = useTranslation();\\n const apolloClient = useApolloClient();\\n const { userState, debouncedUpdateUserState } = useContext(AuthContext);\\n+ const { attemptedAutoTranscribe, markAttempted, setIsAutoTranscribing } =\\n+ useAutoTranscribe();\\n const prevUserStateRef = useRef();\\n const [currentTime, setCurrentTime] = useState(0);\\n const [selectedTab, setSelectedTab] = useState(\\\"transcribe\\\");\\n@@ -520,6 +998,20 @@ function VideoPage() {\\n const [copied, setCopied] = useState(false);\\n const [vttUrl, setVttUrl] = useState(null);\\n const { language } = useContext(LanguageContext);\\n+ const [isUploading, setIsUploading] = useState(false);\\n+ const [youtubePlayer, setYoutubePlayer] = useState(null);\\n+ const [isYTPlaying, setIsYTPlaying] = useState(false);\\n+ const [isRetranscribing, setIsRetranscribing] = useState(false);\\n+ const { addProgressToast } = useProgress();\\n+\\n+ // Update the ref whenever transcripts changes\\n+ useEffect(() => {\\n+ transcriptsRef.current = transcripts;\\n+ }, [transcripts]);\\n+\\n+ useEffect(() => {\\n+ videoInformationRef.current = userState?.transcribe?.videoInformation;\\n+ }, [userState?.transcribe?.videoInformation]);\\n \\n // Handle VTT URL creation and cleanup\\n useEffect(() => {\\n@@ -550,12 +1042,14 @@ function VideoPage() {\\n \\n const updateUserState = useCallback(\\n (updates) => {\\n- debouncedUpdateUserState({\\n- transcribe: {\\n- ...userState?.transcribe,\\n- ...updates,\\n- },\\n- });\\n+ setTimeout(() => {\\n+ debouncedUpdateUserState({\\n+ transcribe: {\\n+ ...userState?.transcribe,\\n+ ...updates,\\n+ },\\n+ });\\n+ }, 0);\\n },\\n [userState?.transcribe, debouncedUpdateUserState],\\n );\\n@@ -582,15 +1076,20 @@ function VideoPage() {\\n setUrl(userState.transcribe?.url);\\n }\\n if (\\n- userState.transcribe?.videoInformation?.videoUrl !==\\n+ videoInformationRef.current?.videoUrl !==\\n prevUserStateRef.current?.transcribe?.videoInformation?.videoUrl\\n ) {\\n- setVideoInformation(userState.transcribe?.videoInformation);\\n- if (userState.transcribe?.videoInformation?.videoLanguages) {\\n- setVideoLanguages(\\n- userState.transcribe.videoInformation.videoLanguages,\\n- );\\n- }\\n+ setVideoInformation(videoInformationRef.current);\\n+ }\\n+\\n+ if (\\n+ videoInformationRef.current?.videoLanguages?.length !==\\n+ prevUserStateRef.current?.transcribe?.videoInformation\\n+ ?.videoLanguages?.length\\n+ ) {\\n+ setVideoLanguages(\\n+ videoInformationRef.current?.videoLanguages || [],\\n+ );\\n }\\n \\n if (\\n@@ -632,7 +1131,7 @@ function VideoPage() {\\n \\n updateUserState({\\n videoInformation: {\\n- ...userState?.transcribe?.videoInformation,\\n+ ...videoInformationRef.current,\\n videoLanguages: initialLanguages,\\n },\\n });\\n@@ -643,12 +1142,13 @@ function VideoPage() {\\n useEffect(() => {\\n if (videoInformation) {\\n updateUserState({\\n- videoInformation: {\\n- ...userState?.transcribe?.videoInformation,\\n- transcripts,\\n- },\\n+ videoInformation: videoInformationRef.current,\\n+ transcripts,\\n });\\n }\\n+ if (transcripts.length) {\\n+ markAttempted(videoInformation?.videoUrl);\\n+ }\\n // eslint-disable-next-line react-hooks/exhaustive-deps\\n }, [transcripts]);\\n \\n@@ -656,24 +1156,26 @@ function VideoPage() {\\n if (videoInformation) {\\n updateUserState({\\n videoInformation: {\\n- ...userState?.transcribe?.videoInformation,\\n+ ...videoInformation,\\n videoLanguages,\\n },\\n });\\n }\\n // eslint-disable-next-line react-hooks/exhaustive-deps\\n- }, [videoLanguages]);\\n+ }, [videoLanguages, videoInformation]);\\n \\n const addSubtitleTrack = useCallback(\\n (transcript) => {\\n if (transcript) {\\n const { text, format, name } = transcript;\\n \\n- setTranscripts((prev) => {\\n+ // First, calculate the new transcript data outside of setState\\n+ // This ensures we have the data even if component unmounts\\n+ const calculateNewTranscripts = (prevTranscripts) => {\\n // Find existing tracks with the same name and get the highest number\\n const baseNameMatch = name.match(/(.*?)(?:\\\\s+\\\\((\\\\d+)\\\\))?$/);\\n const baseName = baseNameMatch[1];\\n- const existingNumbers = prev\\n+ const existingNumbers = prevTranscripts\\n .filter((t) => t.name && t.name.startsWith(baseName))\\n .map((t) => {\\n const match = t.name.match(\\n@@ -684,7 +1186,7 @@ function VideoPage() {\\n \\n // Determine the new name with suffix if needed\\n let newName = name;\\n- if (prev.some((t) => t.name === name)) {\\n+ if (prevTranscripts.some((t) => t.name === name)) {\\n const nextNumber =\\n existingNumbers.length > 0\\n ? Math.max(...existingNumbers) + 1\\n@@ -692,8 +1194,8 @@ function VideoPage() {\\n newName = `${baseName} (${nextNumber})`;\\n }\\n \\n- const updatedTranscripts = [\\n- ...prev,\\n+ return [\\n+ ...prevTranscripts,\\n {\\n text,\\n format,\\n@@ -701,30 +1203,101 @@ function VideoPage() {\\n timestamp: new Date().toISOString(),\\n },\\n ];\\n- setActiveTranscript(updatedTranscripts.length - 1);\\n+ };\\n \\n- updateUserState({\\n- videoInformation: {\\n- ...userState?.transcribe?.videoInformation,\\n- },\\n- transcripts: updatedTranscripts,\\n- });\\n+ // Use the ref to get the current transcripts\\n+ const currentTranscripts = transcriptsRef.current || [];\\n+ const updatedTranscripts =\\n+ calculateNewTranscripts(currentTranscripts);\\n+ const newActiveIndex = updatedTranscripts.length - 1;\\n \\n- return updatedTranscripts;\\n+ // Update component state (this might not execute if component unmounts)\\n+ setTranscripts(updatedTranscripts);\\n+ setActiveTranscript(newActiveIndex);\\n+\\n+ // Always update user state regardless of component mount status\\n+ updateUserState({\\n+ videoInformation: videoInformationRef.current,\\n+ transcripts: updatedTranscripts,\\n+ activeTranscript: newActiveIndex,\\n });\\n }\\n setAddTrackDialogOpen(false);\\n },\\n- [updateUserState, userState?.transcribe?.videoInformation],\\n+ [updateUserState],\\n );\\n \\n- const handleSeek = useCallback((time) => {\\n- const videoElement = document.querySelector(\\\"video\\\");\\n- if (videoElement) {\\n- videoElement.currentTime = time;\\n- }\\n+ const handleSeek = useCallback(\\n+ (time) => {\\n+ if (isYoutubeUrl(videoInformation?.videoUrl)) {\\n+ // For YouTube videos, use the stored player instance\\n+ if (youtubePlayer) {\\n+ try {\\n+ youtubePlayer.seekTo(time, true);\\n+ } catch (error) {\\n+ console.error(\\\"Error seeking YouTube video:\\\", error);\\n+ }\\n+ }\\n+ } else {\\n+ // For regular videos, use the video element API\\n+ const videoElement = document.querySelector(\\\"video\\\");\\n+ if (videoElement) {\\n+ videoElement.currentTime = time;\\n+ }\\n+ }\\n+ },\\n+ [videoInformation?.videoUrl, youtubePlayer],\\n+ );\\n+\\n+ // Add YouTube player state change handler\\n+ const handleYTStateChange = useCallback((event) => {\\n+ // YT.PlayerState.PLAYING === 1\\n+ setIsYTPlaying(event.data === 1);\\n }, []);\\n \\n+ // Update time only when playing\\n+ useEffect(() => {\\n+ let timeUpdateInterval;\\n+\\n+ if (\\n+ isYoutubeUrl(videoInformation?.videoUrl) &&\\n+ youtubePlayer &&\\n+ isYTPlaying\\n+ ) {\\n+ timeUpdateInterval = setInterval(() => {\\n+ try {\\n+ const currentTime = youtubePlayer.getCurrentTime();\\n+ setCurrentTime(currentTime);\\n+ } catch (error) {\\n+ console.error(\\\"Error getting YouTube current time:\\\", error);\\n+ }\\n+ }, 250); // Update 4 times per second\\n+ }\\n+\\n+ return () => {\\n+ if (timeUpdateInterval) {\\n+ clearInterval(timeUpdateInterval);\\n+ }\\n+ };\\n+ }, [videoInformation?.videoUrl, youtubePlayer, isYTPlaying]);\\n+\\n+ // Add the state change event listener when creating YouTube player\\n+ useEffect(() => {\\n+ if (youtubePlayer) {\\n+ youtubePlayer.addEventListener(\\n+ \\\"onStateChange\\\",\\n+ handleYTStateChange,\\n+ );\\n+\\n+ return () => {\\n+ youtubePlayer.removeEventListener(\\n+ \\\"onStateChange\\\",\\n+ handleYTStateChange,\\n+ );\\n+ };\\n+ }\\n+ }, [youtubePlayer, handleYTStateChange]);\\n+\\n const handleActiveTranscriptChange = (index) => {\\n setActiveTranscript(index);\\n updateUserState({\\n@@ -732,6 +1305,180 @@ function VideoPage() {\\n });\\n };\\n \\n+ // Add function to start transcription\\n+ const startTranscription = useCallback(async () => {\\n+ const videoUrl =\\n+ videoInformation?.transcriptionUrl || videoInformation?.videoUrl;\\n+ if (!videoUrl || !apolloClient) return;\\n+\\n+ try {\\n+ setIsAutoTranscribing(true);\\n+ const isYouTubeVideo = isYoutubeUrl(videoUrl);\\n+ const modelOption = isYouTubeVideo ? \\\"Gemini\\\" : \\\"Whisper\\\";\\n+ const _query = getTranscribeQuery(modelOption);\\n+\\n+ const { data } = await apolloClient.query({\\n+ query: _query,\\n+ variables: {\\n+ file: videoUrl,\\n+ language: \\\"\\\", // Auto-detect\\n+ wordTimestamped: false,\\n+ responseFormat: \\\"vtt\\\", // Default to VTT format\\n+ async: true,\\n+ },\\n+ fetchPolicy: \\\"network-only\\\",\\n+ });\\n+\\n+ const dataResult =\\n+ data?.transcribe?.result ||\\n+ data?.transcribe_neuralspace?.result ||\\n+ data?.transcribe_gemini?.result;\\n+\\n+ if (dataResult) {\\n+ addProgressToast(\\n+ dataResult,\\n+ t(\\\"Auto-transcribing\\\") + \\\"...\\\",\\n+ async (finalData) => {\\n+ setIsAutoTranscribing(false);\\n+ addSubtitleTrack({\\n+ text: finalData,\\n+ format: \\\"vtt\\\",\\n+ name: t(\\\"Subtitles\\\"),\\n+ });\\n+ },\\n+ (error) => {\\n+ // Add this error handler to reset the auto-transcribing state when cancelled\\n+ console.error(\\n+ \\\"Transcription error or cancelled:\\\",\\n+ error,\\n+ );\\n+ setIsAutoTranscribing(false);\\n+ },\\n+ );\\n+ }\\n+ } catch (error) {\\n+ console.error(\\\"Auto-transcription error:\\\", error);\\n+ setIsAutoTranscribing(false);\\n+ }\\n+ }, [\\n+ videoInformation?.videoUrl,\\n+ videoInformation?.transcriptionUrl,\\n+ apolloClient,\\n+ addSubtitleTrack,\\n+ t,\\n+ addProgressToast,\\n+ setIsAutoTranscribing,\\n+ ]);\\n+\\n+ // Replace the existing auto-transcription effect\\n+ useEffect(() => {\\n+ const videoUrl = videoInformation?.videoUrl;\\n+ if (videoUrl && !transcripts.length && !attemptedAutoTranscribe) {\\n+ markAttempted(videoUrl);\\n+\\n+ startTranscription();\\n+ }\\n+ }, [\\n+ videoInformation?.videoUrl,\\n+ transcripts.length,\\n+ attemptedAutoTranscribe,\\n+ markAttempted,\\n+ startTranscription,\\n+ ]);\\n+\\n+ // Modify the retranscription function to create a new track instead of updating existing\\n+ const handleRetranscribe = useCallback(async () => {\\n+ if (!videoInformation?.videoUrl || isRetranscribing) return;\\n+\\n+ try {\\n+ setIsRetranscribing(true);\\n+ const queryToUse = QUERIES.TRANSCRIBE_GEMINI;\\n+\\n+ // Get current transcript format\\n+ const currentTranscript = transcripts[activeTranscript];\\n+ const isFormatted =\\n+ currentTranscript.format !== \\\"vtt\\\" &&\\n+ currentTranscript.format !== \\\"\\\";\\n+ const isWordTimestamped =\\n+ currentTranscript.text.includes(\\\"<c.\\\") ||\\n+ (currentTranscript.format === \\\"vtt\\\" &&\\n+ currentTranscript.text.includes(\\\"<c \\\"));\\n+\\n+ const { data } = await apolloClient.query({\\n+ query: queryToUse,\\n+ variables: {\\n+ file: videoInformation.videoUrl,\\n+ language: \\\"\\\", // Auto-detect\\n+ wordTimestamped: isWordTimestamped,\\n+ responseFormat: isFormatted\\n+ ? \\\"formatted\\\"\\n+ : currentTranscript.format === \\\"\\\"\\n+ ? \\\"text\\\"\\n+ : currentTranscript.format,\\n+ async: true,\\n+ },\\n+ fetchPolicy: \\\"network-only\\\",\\n+ });\\n+\\n+ const requestId =\\n+ data?.transcribe?.result || data?.transcribe_gemini?.result;\\n+\\n+ if (requestId) {\\n+ addProgressToast(\\n+ requestId,\\n+ t(\\\"Re-transcribing\\\") + \\\"...\\\",\\n+ async (finalData) => {\\n+ if (isFormatted) {\\n+ const response = await apolloClient.query({\\n+ query: QUERIES.FORMAT_PARAGRAPH_TURBO,\\n+ variables: {\\n+ text: finalData,\\n+ async: false,\\n+ },\\n+ });\\n+\\n+ finalData =\\n+ response.data?.format_paragraph_turbo?.result;\\n+ }\\n+\\n+ // Create a new transcript instead of updating the existing one\\n+ const currentName =\\n+ currentTranscript.name ||\\n+ `Transcript ${activeTranscript + 1}`;\\n+ const newName = `${currentName} (alternative)`;\\n+\\n+ setTranscripts((prev) => [\\n+ ...prev,\\n+ {\\n+ text: finalData,\\n+ format: currentTranscript.format,\\n+ name: newName,\\n+ timestamp: new Date().toISOString(),\\n+ isAlternative: true, // Mark as an alternative generated transcript\\n+ },\\n+ ]);\\n+\\n+ // Set the active transcript to the newly created one\\n+ setActiveTranscript(transcripts.length);\\n+ setIsRetranscribing(false);\\n+ },\\n+ () => setIsRetranscribing(false),\\n+ );\\n+ }\\n+ } catch (error) {\\n+ console.error(\\\"Re-transcription error:\\\", error);\\n+ setIsRetranscribing(false);\\n+ }\\n+ }, [\\n+ videoInformation,\\n+ isRetranscribing,\\n+ transcripts,\\n+ activeTranscript,\\n+ apolloClient,\\n+ t,\\n+ addProgressToast,\\n+ ]);\\n+\\n if (!videoInformation && !transcripts?.length) {\\n return (\\n <InitialView\\n@@ -752,55 +1499,31 @@ function VideoPage() {\\n \\n return (\\n <TranscribeErrorBoundary>\\n- <div className=\\\"p-2\\\">\\n+ <div>\\n <div className=\\\"flex flex-col gap-4 mb-4\\\">\\n- <div className=\\\"flex gap-4 justify-between\\\">\\n- <div className=\\\"grow min-w-0 sm:w-[calc(100%-13rem)] sm:grow-0\\\">\\n- {videoInformation?.videoUrl && (\\n- <div className=\\\"flex gap-2 items-center py-1 px-2 bg-gray-100 rounded-md grow min-w-0\\\">\\n- <div className=\\\"text-xs text-gray-500 truncate\\\">\\n- {videoLanguages[activeLanguage]?.url ||\\n- videoInformation.videoUrl}\\n- </div>\\n- <button\\n- onClick={() =>\\n- handleCopy(\\n- videoLanguages[activeLanguage]\\n- ?.url ||\\n- videoInformation.videoUrl,\\n+ <div className=\\\"flex gap-4 justify-end\\\">\\n+ <div className=\\\"flex-shrink-0 sm:w-[13rem] flex justify-end\\\">\\n+ <div>\\n+ <button\\n+ onClick={() => {\\n+ if (\\n+ window.confirm(\\n+ t(\\n+ \\\"Are you sure you want to start over?\\\",\\n+ ),\\n )\\n+ ) {\\n+ markAttempted(false);\\n+ clearVideoInformation();\\n }\\n- className=\\\"p-1 hover:bg-gray-100 rounded transition-colors flex-shrink-0\\\"\\n- title=\\\"Copy URL\\\"\\n- >\\n- {copied ? (\\n- <CheckIcon className=\\\"h-4 w-4 text-green-500\\\" />\\n- ) : (\\n- <CopyIcon className=\\\"h-4 w-4 text-gray-500\\\" />\\n- )}\\n- </button>\\n- </div>\\n- )}\\n- </div>\\n- <div className=\\\"flex-shrink-0 sm:w-[13rem] flex justify-end\\\">\\n- <button\\n- onClick={() => {\\n- if (\\n- window.confirm(\\n- t(\\n- \\\"Are you sure you want to start over?\\\",\\n- ),\\n- )\\n- ) {\\n- clearVideoInformation();\\n- }\\n- }}\\n- className=\\\"lb-outline-secondary lb-sm flex items-center gap-2 flex-shrink-0\\\"\\n- aria-label=\\\"Clear video\\\"\\n- >\\n- <RefreshCwIcon className=\\\"w-4 h-4\\\" />\\n- {t(\\\"Start over\\\")}\\n- </button>\\n+ }}\\n+ className=\\\"lb-outline-secondary lb-sm flex items-center gap-2 flex-shrink-0\\\"\\n+ aria-label=\\\"Clear video\\\"\\n+ >\\n+ <RefreshCwIcon className=\\\"w-4 h-4\\\" />\\n+ {t(\\\"Start over\\\")}\\n+ </button>\\n+ </div>\\n </div>\\n </div>\\n </div>\\n@@ -809,120 +1532,290 @@ function VideoPage() {\\n {isValidUrl(videoInformation?.videoUrl) ? (\\n <>\\n <div className=\\\"flex gap-4 flex-col sm:flex-row\\\">\\n- <div className=\\\"sm:w-[calc(100%-13rem)]\\\">\\n+ <div className=\\\"sm:w-[calc(100%-13rem)] flex flex-col gap-3\\\">\\n <VideoPlayer\\n+ setYoutubePlayer={setYoutubePlayer}\\n videoLanguages={videoLanguages}\\n activeLanguage={activeLanguage}\\n- transcripts={transcripts}\\n- activeTranscript={activeTranscript}\\n onTimeUpdate={setCurrentTime}\\n- videoInformation={videoInformation}\\n vttUrl={vttUrl}\\n+ videoInformation={videoInformation}\\n+ copied={copied}\\n+ handleCopy={handleCopy}\\n />\\n </div>\\n <div className=\\\"flex flex-col gap-2 sm:w-[13rem]\\\">\\n- <div className=\\\"border rounded-lg border-gray-200/50 p-3 space-y-3\\\">\\n- <div className=\\\"text-sm font-semibold text-gray-500\\\">\\n- {t(\\\"Video languages\\\")}\\n- </div>\\n- <div className=\\\"flex flex-col gap-2\\\">\\n- {videoLanguages.map(\\n- (lang, idx) => (\\n- <div\\n- key={idx}\\n- className=\\\"flex items-center\\\"\\n- >\\n- <div className=\\\"flex w-[13rem] rounded-md border border-gray-200 overflow-hidden\\\">\\n- <button\\n- onClick={() => {\\n+ {isYoutubeUrl(\\n+ videoInformation?.videoUrl,\\n+ ) ? null : (\\n+ <>\\n+ {(() => {\\n+ // Use the existing isAudioUrl function to detect audio files\\n+ const isAudioFile =\\n+ videoInformation?.videoUrl\\n+ ? isAudioUrl(\\n+ videoInformation.videoUrl,\\n+ )\\n+ : false;\\n+\\n+ // Only show audio tracks section for video files\\n+ if (isAudioFile) {\\n+ return null;\\n+ }\\n+\\n+ return (\\n+ <div className=\\\"border rounded-lg border-gray-200/50 p-3 space-y-3\\\">\\n+ <div className=\\\"text-sm text-sky-600 font-semibold text-gray-500 flex items-center gap-2\\\">\\n+ <Volume2Icon className=\\\"h-4 w-4\\\" />\\n+ {t(\\n+ \\\"Audio tracks\\\",\\n+ )}\\n+ </div>\\n+\\n+ {/* Mobile Select View */}\\n+ <div className=\\\"sm:hidden\\\">\\n+ <Select\\n+ value={activeLanguage.toString()}\\n+ onValueChange={(\\n+ value,\\n+ ) =>\\n setActiveLanguage(\\n- idx,\\n- );\\n- }}\\n- className={`grow truncate text-start text-xs px-3 py-1.5 hover:bg-sky-100 active:bg-sky-200 transition-colors\\n- ${activeLanguage === idx ? \\\"bg-sky-50 text-gray-900\\\" : \\\"text-gray-600\\\"}`}\\n+ parseInt(\\n+ value,\\n+ ),\\n+ )\\n+ }\\n >\\n- {lang.label}\\n- </button>\\n- <div className=\\\"flex\\\">\\n- {idx !==\\n- 0 &&\\n- activeLanguage ===\\n- idx && (\\n- <button\\n- onClick={(\\n- e,\\n- ) => {\\n- e.stopPropagation();\\n- if (\\n- window.confirm(\\n- t(\\n- \\\"Are you sure you want to delete this language track?\\\",\\n- ),\\n- )\\n- ) {\\n- const newVideoLanguages =\\n- videoLanguages.filter(\\n- (\\n- _,\\n- i,\\n+ <SelectTrigger className=\\\"w-full text-xs\\\">\\n+ <SelectValue>\\n+ {videoLanguages[\\n+ activeLanguage\\n+ ]\\n+ ?.label ||\\n+ new Intl.DisplayNames(\\n+ [\\n+ language,\\n+ ],\\n+ {\\n+ type: \\\"language\\\",\\n+ },\\n+ ).of(\\n+ videoLanguages[\\n+ activeLanguage\\n+ ]\\n+ ?.code ||\\n+ \\\"en\\\",\\n+ )}\\n+ </SelectValue>\\n+ </SelectTrigger>\\n+ <SelectContent>\\n+ {videoLanguages.map(\\n+ (\\n+ lang,\\n+ idx,\\n+ ) => (\\n+ <SelectItem\\n+ key={\\n+ idx\\n+ }\\n+ value={idx.toString()}\\n+ className=\\\"text-xs\\\"\\n+ >\\n+ <div className=\\\"flex items-center justify-between w-full\\\">\\n+ <span>\\n+ {lang.label ||\\n+ new Intl.DisplayNames(\\n+ [\\n+ language,\\n+ ],\\n+ {\\n+ type: \\\"language\\\",\\n+ },\\n+ ).of(\\n+ lang.code,\\n+ )}\\n+ </span>\\n+ <div className=\\\"flex items-center gap-2\\\">\\n+ {idx !==\\n+ 0 && (\\n+ <button\\n+ onClick={(\\n+ e,\\n+ ) => {\\n+ e.stopPropagation();\\n+ if (\\n+ window.confirm(\\n+ t(\\n+ \\\"Are you sure you want to delete this language track?\\\",\\n+ ),\\n+ )\\n+ ) {\\n+ const newVideoLanguages =\\n+ videoLanguages.filter(\\n+ (\\n+ _,\\n+ i,\\n+ ) =>\\n+ i !==\\n+ idx,\\n+ );\\n+ setVideoLanguages(\\n+ newVideoLanguages,\\n+ );\\n+ setActiveLanguage(\\n+ 0,\\n+ );\\n+ }\\n+ }}\\n+ className=\\\"text-gray-500 hover:text-red-500 transition-colors\\\"\\n+ >\\n+ <TrashIcon className=\\\"h-3 w-3\\\" />\\n+ </button>\\n+ )}\\n+ <a\\n+ href={\\n+ lang.url\\n+ }\\n+ download={`video-${lang.code}.mp4`}\\n+ onClick={(\\n+ e,\\n ) =>\\n- i !==\\n- idx,\\n- );\\n- setVideoLanguages(\\n- newVideoLanguages,\\n- );\\n+ e.stopPropagation()\\n+ }\\n+ className=\\\"text-gray-500\\\"\\n+ >\\n+ <DownloadIcon className=\\\"h-3.5 w-3.5\\\" />\\n+ </a>\\n+ </div>\\n+ </div>\\n+ </SelectItem>\\n+ ),\\n+ )}\\n+ </SelectContent>\\n+ </Select>\\n+ </div>\\n+\\n+ {/* Desktop List View */}\\n+ <div className=\\\"hidden sm:flex sm:flex-col sm:gap-2\\\">\\n+ {videoLanguages.map(\\n+ (\\n+ lang,\\n+ idx,\\n+ ) => (\\n+ <div\\n+ key={\\n+ idx\\n+ }\\n+ className=\\\"flex items-center\\\"\\n+ >\\n+ <div className=\\\"flex w-[13rem] rounded-md border border-gray-200 overflow-hidden\\\">\\n+ <button\\n+ onClick={() => {\\n setActiveLanguage(\\n- 0,\\n+ idx,\\n );\\n- }\\n- }}\\n- className={\\n- \\\"px-2 bg-sky-50 text-gray-500 hover:text-red-500 transition-colors border-gray-200 flex items-center cursor-pointer\\\"\\n- }\\n- title={t(\\n- \\\"Delete language\\\",\\n- )}\\n- >\\n- <TrashIcon className=\\\"h-3 w-3\\\" />\\n- </button>\\n- )}\\n- <a\\n- target=\\\"_blank\\\"\\n- rel=\\\"noreferrer\\\"\\n- href={\\n- lang.url\\n- }\\n- download={`video-${lang.code}.mp4`}\\n- className=\\\"px-2 hover:bg-sky-50 transition-colors border-l border-gray-200 flex items-center cursor-pointer\\\"\\n- onClick={(\\n- e,\\n- ) =>\\n- e.stopPropagation()\\n- }\\n- title={t(\\n- \\\"Download video\\\",\\n- )}\\n- >\\n- <DownloadIcon className=\\\"h-3.5 w-3.5 text-gray-500\\\" />\\n- </a>\\n- </div>\\n+ }}\\n+ className={`grow truncate text-start text-xs px-3 py-1.5 hover:bg-sky-100 active:bg-sky-200 transition-colors\\n+ ${activeLanguage === idx ? \\\"bg-sky-50 text-gray-900\\\" : \\\"text-gray-600\\\"}`}\\n+ >\\n+ {lang.label ||\\n+ new Intl.DisplayNames(\\n+ [\\n+ language,\\n+ ],\\n+ {\\n+ type: \\\"language\\\",\\n+ },\\n+ ).of(\\n+ lang.code,\\n+ )}\\n+ </button>\\n+ <div className=\\\"flex\\\">\\n+ {idx !==\\n+ 0 &&\\n+ activeLanguage ===\\n+ idx && (\\n+ <button\\n+ onClick={(\\n+ e,\\n+ ) => {\\n+ e.stopPropagation();\\n+ if (\\n+ window.confirm(\\n+ t(\\n+ \\\"Are you sure you want to delete this language track?\\\",\\n+ ),\\n+ )\\n+ ) {\\n+ const newVideoLanguages =\\n+ videoLanguages.filter(\\n+ (\\n+ _,\\n+ i,\\n+ ) =>\\n+ i !==\\n+ idx,\\n+ );\\n+ setVideoLanguages(\\n+ newVideoLanguages,\\n+ );\\n+ setActiveLanguage(\\n+ 0,\\n+ );\\n+ }\\n+ }}\\n+ className=\\\"px-2 bg-sky-50 text-gray-500 hover:text-red-500 transition-colors border-gray-200 flex items-center cursor-pointer\\\"\\n+ title={t(\\n+ \\\"Delete language\\\",\\n+ )}\\n+ >\\n+ <TrashIcon className=\\\"h-3 w-3\\\" />\\n+ </button>\\n+ )}\\n+ <a\\n+ target=\\\"_blank\\\"\\n+ rel=\\\"noreferrer\\\"\\n+ href={\\n+ lang.url\\n+ }\\n+ download={`video-${lang.code}.mp4`}\\n+ className=\\\"px-2 hover:bg-sky-50 transition-colors border-l border-gray-200 flex items-center cursor-pointer\\\"\\n+ onClick={(\\n+ e,\\n+ ) =>\\n+ e.stopPropagation()\\n+ }\\n+ title={t(\\n+ \\\"Download video\\\",\\n+ )}\\n+ >\\n+ <DownloadIcon className=\\\"h-3.5 w-3.5 text-gray-500\\\" />\\n+ </a>\\n+ </div>\\n+ </div>\\n+ </div>\\n+ ),\\n+ )}\\n </div>\\n+\\n+ <button\\n+ onClick={() =>\\n+ setShowTranslateDialog(\\n+ true,\\n+ )\\n+ }\\n+ className=\\\"lb-outline-secondary lb-sm flex items-center gap-1 w-full\\\"\\n+ >\\n+ <PlusIcon className=\\\"h-4 w-4\\\" />\\n+ {t(\\n+ \\\"Add audio track\\\",\\n+ )}\\n+ </button>\\n </div>\\n- ),\\n- )}\\n- </div>\\n- <button\\n- onClick={() =>\\n- setShowTranslateDialog(true)\\n- }\\n- className=\\\"lb-outline-secondary lb-sm flex items-center gap-1 w-full\\\"\\n- >\\n- <PlusIcon className=\\\"h-4 w-4\\\" />\\n- {t(\\\"Add translation\\\")}\\n- </button>\\n- </div>\\n+ );\\n+ })()}\\n+ </>\\n+ )}\\n </div>\\n </div>\\n \\n@@ -933,7 +1826,7 @@ function VideoPage() {\\n <DialogContent className=\\\"max-w-3xl\\\">\\n <DialogHeader>\\n <DialogTitle>\\n- {t(\\\"Add Video Language\\\")}\\n+ {t(\\\"Add audio track\\\")}\\n </DialogTitle>\\n <DialogDescription>\\n {t(\\n@@ -943,173 +1836,6 @@ function VideoPage() {\\n </DialogHeader>\\n <AzureVideoTranslate\\n url={videoInformation?.videoUrl}\\n- onComplete={async (\\n- targetLocale,\\n- outputUrl,\\n- vttUrls,\\n- ) => {\\n- const originalVttUrl =\\n- vttUrls.original;\\n- const translatedVttUrl =\\n- vttUrls.translated;\\n-\\n- // Fetch the VTT content\\n- try {\\n- const addVtt = async (\\n- vttUrl,\\n- name,\\n- ) => {\\n- if (!vttUrl) {\\n- return null;\\n- }\\n- const response =\\n- await fetch(vttUrl);\\n- const vttContent =\\n- await response.text();\\n-\\n- return {\\n- url: vttUrl,\\n- text: vttContent,\\n- format: \\\"vtt\\\",\\n- name,\\n- };\\n- };\\n-\\n- const newVideoLanguages = [\\n- ...videoLanguages,\\n- {\\n- code: targetLocale,\\n- label: new Intl.DisplayNames(\\n- [language],\\n- {\\n- type: \\\"language\\\",\\n- },\\n- ).of(targetLocale),\\n- url: outputUrl,\\n- },\\n- ];\\n-\\n- let newTranscripts = [\\n- ...transcripts,\\n- ];\\n-\\n- // Try to add original subtitles if they don't exist\\n- try {\\n- const autoSubtitlesExist =\\n- transcripts.some(\\n- (transcript) =>\\n- transcript.name ===\\n- t(\\n- \\\"Original Subtitles\\\",\\n- ),\\n- );\\n-\\n- if (\\n- !autoSubtitlesExist &&\\n- originalVttUrl\\n- ) {\\n- const originalTranscript =\\n- await addVtt(\\n- originalVttUrl,\\n- t(\\n- \\\"Original Subtitles\\\",\\n- ),\\n- );\\n- if (\\n- originalTranscript\\n- ) {\\n- newTranscripts.push(\\n- originalTranscript,\\n- );\\n- }\\n- }\\n- } catch (error) {\\n- console.error(\\n- \\\"Failed to fetch original VTT content:\\\",\\n- error,\\n- );\\n- // Continue with translation even if original subtitles fail\\n- }\\n-\\n- // Try to add translated subtitles\\n- try {\\n- if (translatedVttUrl) {\\n- const translatedTranscript =\\n- await addVtt(\\n- translatedVttUrl,\\n- t(\\n- \\\"{{language}} Subtitles\\\",\\n- {\\n- language:\\n- new Intl.DisplayNames(\\n- [\\n- language,\\n- ],\\n- {\\n- type: \\\"language\\\",\\n- },\\n- ).of(\\n- targetLocale,\\n- ),\\n- },\\n- ),\\n- );\\n- if (\\n- translatedTranscript\\n- ) {\\n- newTranscripts.push(\\n- translatedTranscript,\\n- );\\n- }\\n- }\\n- } catch (error) {\\n- console.error(\\n- \\\"Failed to fetch translated VTT content:\\\",\\n- error,\\n- );\\n- }\\n-\\n- // Update state with whatever we successfully got\\n- setTranscripts(\\n- newTranscripts,\\n- );\\n- setVideoLanguages(\\n- newVideoLanguages,\\n- );\\n- setActiveLanguage(\\n- newVideoLanguages.length -\\n- 1,\\n- );\\n- setActiveTranscript(\\n- newTranscripts.length -\\n- 1,\\n- );\\n- updateUserState({\\n- videoInformation: {\\n- ...userState\\n- ?.transcribe\\n- ?.videoInformation,\\n- videoLanguages:\\n- newVideoLanguages,\\n- },\\n- transcripts:\\n- newTranscripts,\\n- activeTranscript:\\n- newTranscripts.length -\\n- 1,\\n- });\\n-\\n- setShowTranslateDialog(\\n- false,\\n- );\\n- } catch (error) {\\n- console.error(\\n- \\\"Failed to fetch VTT content:\\\",\\n- error,\\n- );\\n- // Optionally show an error message to the user\\n- }\\n- }}\\n onQueued={(requestId) => {\\n setShowTranslateDialog(false);\\n }}\\n@@ -1129,7 +1855,12 @@ function VideoPage() {\\n {showVideoInput && (\\n <Dialog\\n open={showVideoInput}\\n- onOpenChange={setShowVideoInput}\\n+ onOpenChange={(open) => {\\n+ // Prevent closing if upload is in progress\\n+ if (!isUploading) {\\n+ setShowVideoInput(open);\\n+ }\\n+ }}\\n >\\n <DialogContent className=\\\"min-w-[80%] max-h-[80%] overflow-auto\\\">\\n <DialogHeader>\\n@@ -1153,11 +1884,19 @@ function VideoPage() {\\n videoInfo?.videoUrl ||\\n \\\"\\\",\\n });\\n+ // Only close the modal when upload is complete\\n setShowVideoInput(false);\\n }}\\n onCancel={() =>\\n+ !isUploading &&\\n setShowVideoInput(false)\\n }\\n+ onUploadStart={() =>\\n+ setIsUploading(true)\\n+ }\\n+ onUploadComplete={() =>\\n+ setIsUploading(false)\\n+ }\\n />\\n </DialogContent>\\n </Dialog>\\n@@ -1194,15 +1933,19 @@ function VideoPage() {\\n setIsEditing={setIsEditing}\\n onDeleteTrack={() => {\\n const updatedTranscripts = transcripts.filter(\\n- (_, index) => index !== activeTranscript,\\n+ (_, index) => index !== (activeTranscript || 0),\\n );\\n+\\n setTranscripts(updatedTranscripts);\\n- setActiveTranscript(\\n- Math.max(0, updatedTranscripts.length - 1),\\n+ const newActiveIndex = Math.max(\\n+ 0,\\n+ activeTranscript - 1,\\n );\\n+ setActiveTranscript(newActiveIndex);\\n updateUserState({\\n videoInformation: {\\n- ...userState?.transcribe?.videoInformation,\\n+ ...videoInformationRef.current,\\n+ activeTranscript: newActiveIndex,\\n },\\n transcripts: updatedTranscripts,\\n });\\n@@ -1235,10 +1978,8 @@ function VideoPage() {\\n \\n // Update user state with new transcripts\\n updateUserState({\\n- videoInformation: {\\n- ...userState?.transcribe\\n- ?.videoInformation,\\n- },\\n+ videoInformation:\\n+ videoInformationRef.current,\\n transcripts: transcripts.map(\\n (transcript, index) =>\\n index === activeTranscript\\n@@ -1250,6 +1991,12 @@ function VideoPage() {\\n ),\\n });\\n }}\\n+ onRetranscribe={handleRetranscribe}\\n+ isRetranscribing={isRetranscribing}\\n+ showRetranscribeButton={\\n+ !transcripts[activeTranscript].isAlternative\\n+ }\\n+ url={videoInformation?.videoUrl || url}\\n />\\n </>\\n )}\\ndiff --git a/src/components/transcribe/VideoSelector.js b/src/components/transcribe/VideoSelector.js\\nindex 0fd3696..8789e38 100644\\n--- a/src/components/transcribe/VideoSelector.js\\n+++ b/src/components/transcribe/VideoSelector.js\\n@@ -1,11 +1,11 @@\\n import { useQuery } from \\\"@tanstack/react-query\\\";\\n-import { CheckIcon, SearchIcon } from \\\"lucide-react\\\";\\n-import { useState, useEffect } from \\\"react\\\";\\n+import { CheckIcon, SearchIcon, XIcon } from \\\"lucide-react\\\";\\n+import { useEffect, useState } from \\\"react\\\";\\n+import { useTranslation } from \\\"react-i18next\\\";\\n import config from \\\"../../../config\\\";\\n import LoadingButton from \\\"../editor/LoadingButton\\\";\\n-import { useTranslation } from \\\"react-i18next\\\";\\n \\n-const VideoSelector = ({ url, onSelect }) => {\\n+const VideoSelector = ({ url, onSelect, onClose }) => {\\n const [debouncedUrl, setDebouncedUrl] = useState(url);\\n const [searchInput, setSearchInput] = useState(url);\\n const fetchUrlSource = config?.transcribe?.fetchUrlSource;\\n@@ -24,12 +24,17 @@ const VideoSelector = ({ url, onSelect }) => {\\n });\\n \\n useEffect(() => {\\n- if (data && !data.results?.length) {\\n- onSelect({ videoUrl: ensureHttps(debouncedUrl) });\\n+ if (\\n+ data?.results?.length === 1 &&\\n+ data?.results[0]?.fromExternalChannel\\n+ ) {\\n+ onSelect({\\n+ videoUrl: ensureHttps(data?.results[0]?.videoUrl),\\n+ transcriptionUrl: ensureHttps(data?.results[0]?.url),\\n+ });\\n }\\n- }, [data, onSelect, debouncedUrl]);\\n+ }, [data, onSelect]);\\n \\n- // Add helper function\\n const ensureHttps = (url) => {\\n if (url?.startsWith(\\\"http://\\\")) {\\n return url.replace(\\\"http://\\\", \\\"https://\\\");\\n@@ -40,10 +45,17 @@ const VideoSelector = ({ url, onSelect }) => {\\n if (!fetchUrlSource) return null;\\n \\n return (\\n- <div className=\\\"mb-4 min-h-[300px]\\\">\\n+ <div className=\\\"mb-4 min-h-[300px] p-4 border relative rounded-md\\\">\\n+ <button\\n+ onClick={onClose}\\n+ className=\\\"absolute top-2 right-2 p-2 hover:bg-gray-100 rounded-full\\\"\\n+ aria-label=\\\"Close\\\"\\n+ >\\n+ <XIcon className=\\\"w-4 h-4\\\" />\\n+ </button>\\n <div className=\\\"mb-1\\\">\\n <label htmlFor=\\\"url-input\\\" className=\\\"font-semibold\\\">\\n- {t(\\\"Search by video URL or title\\\")}\\n+ {t(\\\"Search the AJ library by video URL or title\\\")}\\n </label>\\n </div>\\n <div className=\\\"flex gap-2\\\">\\n@@ -100,11 +112,22 @@ const VideoSelector = ({ url, onSelect }) => {\\n {t(\\\"Match confidence:\\\")}{\\\" \\\"}\\n {Math.round(result.similarity * 100)}%\\n </p>\\n- <video\\n- className=\\\"w-full aspect-video object-cover mb-4 rounded\\\"\\n- controls\\n- src={result.videoUrl || result.url}\\n- />\\n+ {result.isYouTube ? (\\n+ <iframe\\n+ className=\\\"w-full aspect-video mb-4 rounded\\\"\\n+ src={result.videoUrl}\\n+ allowFullScreen\\n+ title=\\\"YouTube video player\\\"\\n+ frameBorder=\\\"0\\\"\\n+ allow=\\\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\\\"\\n+ />\\n+ ) : (\\n+ <video\\n+ className=\\\"w-full aspect-video object-cover mb-4 rounded\\\"\\n+ controls\\n+ src={result.videoUrl || result.url}\\n+ />\\n+ )}\\n <div className=\\\"flex justify-center gap-2 text-xs\\\">\\n <button\\n className=\\\"lb-success\\\"\\n@@ -127,6 +150,12 @@ const VideoSelector = ({ url, onSelect }) => {\\n ))}\\n </div>\\n </div>\\n+ ) : data?.results ? (\\n+ <p className=\\\"mt-4 text-gray-600\\\">\\n+ {t(\\\"No matching videos found\\\")}\\n+ </p>\\n+ ) : data?.error ? (\\n+ <p className=\\\"mt-4\\\">{data.error}</p>\\n ) : null}\\n </div>\\n );\\ndiff --git a/src/contexts/AutoTranscribeContext.js b/src/contexts/AutoTranscribeContext.js\\nnew file mode 100644\\nindex 0000000..e3179b8\\n--- /dev/null\\n+++ b/src/contexts/AutoTranscribeContext.js\\n@@ -0,0 +1,37 @@\\n+\\\"use client\\\";\\n+\\n+import { createContext, useContext, useState } from \\\"react\\\";\\n+\\n+const AutoTranscribeContext = createContext({\\n+ attemptedAutoTranscribe: {},\\n+ isAutoTranscribing: false,\\n+ markAttempted: () => {},\\n+ setIsAutoTranscribing: () => {},\\n+});\\n+\\n+export function AutoTranscribeProvider({ children }) {\\n+ const [attemptedAutoTranscribe, setAttemptedAutoTranscribe] =\\n+ useState(false);\\n+ const [isAutoTranscribing, setIsAutoTranscribing] = useState(false);\\n+\\n+ const markAttempted = (value) => {\\n+ setAttemptedAutoTranscribe(value);\\n+ };\\n+\\n+ return (\\n+ <AutoTranscribeContext.Provider\\n+ value={{\\n+ attemptedAutoTranscribe,\\n+ isAutoTranscribing,\\n+ markAttempted,\\n+ setIsAutoTranscribing,\\n+ }}\\n+ >\\n+ {children}\\n+ </AutoTranscribeContext.Provider>\\n+ );\\n+}\\n+\\n+export function useAutoTranscribe() {\\n+ return useContext(AutoTranscribeContext);\\n+}\\ndiff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js\\nnew file mode 100644\\nindex 0000000..b486f68\\n--- /dev/null\\n+++ b/src/contexts/NotificationContext.js\\n@@ -0,0 +1,33 @@\\n+import { createContext, useContext, useState } from \\\"react\\\";\\n+\\n+const NotificationContext = createContext();\\n+\\n+export function NotificationProvider({ children }) {\\n+ const [isNotificationOpen, setIsNotificationOpen] = useState(false);\\n+\\n+ const openNotifications = () => setIsNotificationOpen(true);\\n+ const closeNotifications = () => setIsNotificationOpen(false);\\n+\\n+ return (\\n+ <NotificationContext.Provider\\n+ value={{\\n+ isNotificationOpen,\\n+ openNotifications,\\n+ closeNotifications,\\n+ setIsNotificationOpen,\\n+ }}\\n+ >\\n+ {children}\\n+ </NotificationContext.Provider>\\n+ );\\n+}\\n+\\n+export const useNotificationsContext = () => {\\n+ const context = useContext(NotificationContext);\\n+ if (!context) {\\n+ throw new Error(\\n+ \\\"useNotifications must be used within a NotificationProvider\\\",\\n+ );\\n+ }\\n+ return context;\\n+};\\ndiff --git a/src/contexts/ProgressContext.js b/src/contexts/ProgressContext.js\\nindex db18db4..f0bb121 100644\\n--- a/src/contexts/ProgressContext.js\\n+++ b/src/contexts/ProgressContext.js\\n@@ -93,6 +93,7 @@ function ProgressToast({\\n subscriptionRef.current();\\n }\\n }, timeout);\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n }, [timeout, onError, requestId]);\\n \\n // Initial timeout setup\\n@@ -136,6 +137,17 @@ function ProgressToast({\\n resetTimeout();\\n }\\n \\n+ // Check for error in the requestProgress data\\n+ if (data?.requestProgress?.error) {\\n+ const progressError = new Error(data.requestProgress.error);\\n+ onError?.(progressError);\\n+ setErrorMessage(\\n+ data.requestProgress.error || ERROR_MESSAGES.generic,\\n+ );\\n+ toast.update(requestId, { closeButton: true });\\n+ return;\\n+ }\\n+\\n const result = data?.requestProgress?.data;\\n const newProgress = Math.max(\\n (data?.requestProgress?.progress || 0) * 100,\\n@@ -149,7 +161,8 @@ function ProgressToast({\\n if (\\n result &&\\n data.requestProgress.progress === 1 &&\\n- !onCompleteCalledRef.current\\n+ !onCompleteCalledRef.current &&\\n+ !isCancelled\\n ) {\\n if (timeoutRef.current) {\\n clearTimeout(timeoutRef.current);\\n@@ -179,6 +192,7 @@ function ProgressToast({\\n toast.update(requestId, { closeButton: true });\\n });\\n }\\n+ // eslint-disable-next-line react-hooks/exhaustive-deps\\n }, [\\n data,\\n error,\\n@@ -188,6 +202,7 @@ function ProgressToast({\\n onError,\\n activeToasts,\\n resetTimeout,\\n+ isCancelled,\\n ]);\\n \\n const handleCancel = () => {\\n@@ -205,6 +220,9 @@ function ProgressToast({\\n setErrorMessage(t(\\\"Operation cancelled\\\"));\\n activeToasts?.delete(requestId);\\n toast.update(requestId, { closeButton: true });\\n+\\n+ // Set onCompleteCalledRef to true to prevent completion callback\\n+ onCompleteCalledRef.current = true;\\n }\\n };\\n \\ndiff --git a/src/db.mjs b/src/db.mjs\\nindex a06fe87..1adc015 100644\\n--- a/src/db.mjs\\n+++ b/src/db.mjs\\n@@ -5,9 +5,18 @@ import mongoose from \\\"mongoose\\\";\\n // otherwise chooses the first key in the key vault if available, if not creates a new key\\n const { MONGO_URI, MONGO_ENCRYPTION_KEY, MONGO_DATAKEY_UUID } = process.env;\\n \\n+// Default connection options - using only supported options\\n+const DEFAULT_CONNECTION_OPTIONS = {\\n+ serverSelectionTimeoutMS: 30000, // Increase server selection timeout\\n+ socketTimeoutMS: 45000, // Increase socket timeout\\n+ connectTimeoutMS: 30000, // Increase connection timeout\\n+ maxPoolSize: 10, // Control the maximum number of connections in the pool\\n+ bufferCommands: false, // Prevent buffering commands when disconnected\\n+};\\n+\\n export async function connectToDatabase() {\\n if (!MONGO_ENCRYPTION_KEY) {\\n- await mongoose.connect(MONGO_URI);\\n+ await mongoose.connect(MONGO_URI, DEFAULT_CONNECTION_OPTIONS);\\n console.log(\\n \\\"MONGO_ENCRYPTION_KEY not found. Connected to MongoDB in development mode (no encryption)\\\",\\n );\\n@@ -51,6 +60,7 @@ export async function connectToDatabase() {\\n try {\\n conn = await mongoose\\n .createConnection(MONGO_URI, {\\n+ ...DEFAULT_CONNECTION_OPTIONS,\\n autoEncryption: autoEncryptionOptions,\\n })\\n .asPromise();\\n@@ -247,9 +257,12 @@ export async function connectToDatabase() {\\n autoEncryptionOptions.schemaMap = schemaMap;\\n \\n console.log(\\\"Connecting to MongoDB with encryption\\\");\\n- await mongoose.connect(MONGO_URI, {\\n+ const encryptionConnectionOptions = {\\n+ ...DEFAULT_CONNECTION_OPTIONS,\\n autoEncryption: autoEncryptionOptions,\\n- });\\n+ };\\n+\\n+ await mongoose.connect(MONGO_URI, encryptionConnectionOptions);\\n }\\n \\n export async function closeDatabaseConnection() {\\ndiff --git a/src/graphql.js b/src/graphql.js\\nindex 659fcf3..dfdc4ff 100644\\n--- a/src/graphql.js\\n+++ b/src/graphql.js\\n@@ -154,7 +154,7 @@ const SYS_SAVE_MEMORY = gql`\\n }\\n `;\\n \\n-const RAG_START = gql`\\n+const SYS_ENTITY_START = gql`\\n query RagStart(\\n $chatHistory: [MultiMessage]!\\n $dataSources: [String]\\n@@ -168,8 +168,9 @@ const RAG_START = gql`\\n $aiStyle: String\\n $title: String\\n $codeRequestId: String\\n+ $stream: Boolean\\n ) {\\n- rag_start(\\n+ sys_entity_start(\\n chatHistory: $chatHistory\\n dataSources: $dataSources\\n contextId: $contextId\\n@@ -182,6 +183,7 @@ const RAG_START = gql`\\n aiStyle: $aiStyle\\n title: $title\\n codeRequestId: $codeRequestId\\n+ stream: $stream\\n ) {\\n result\\n contextId\\n@@ -423,6 +425,38 @@ const TRANSCRIBE = gql`\\n }\\n `;\\n \\n+const TRANSCRIBE_GEMINI = gql`\\n+ query TranscribeGemini(\\n+ $file: String!\\n+ $text: String\\n+ $language: String\\n+ $wordTimestamped: Boolean\\n+ $maxLineCount: Int\\n+ $maxLineWidth: Int\\n+ $maxWordsPerLine: Int\\n+ $highlightWords: Boolean\\n+ $responseFormat: String\\n+ $async: Boolean\\n+ $contextId: String\\n+ ) {\\n+ transcribe_gemini(\\n+ file: $file\\n+ text: $text\\n+ language: $language\\n+ wordTimestamped: $wordTimestamped\\n+ maxLineCount: $maxLineCount\\n+ maxLineWidth: $maxLineWidth\\n+ maxWordsPerLine: $maxWordsPerLine\\n+ highlightWords: $highlightWords\\n+ responseFormat: $responseFormat\\n+ async: $async\\n+ contextId: $contextId\\n+ ) {\\n+ result\\n+ }\\n+ }\\n+`;\\n+\\n const TRANSCRIBE_NEURALSPACE = gql`\\n query TranscribeNeuralSpace(\\n $file: String!\\n@@ -544,6 +578,7 @@ const REQUEST_PROGRESS = gql`\\n data\\n progress\\n info\\n+ error\\n }\\n }\\n `;\\n@@ -722,7 +757,7 @@ const QUERIES = {\\n IMAGE_FLUX,\\n SYS_READ_MEMORY,\\n SYS_SAVE_MEMORY,\\n- RAG_START,\\n+ SYS_ENTITY_START,\\n SYS_ENTITY_CONTINUE,\\n EXPAND_STORY,\\n FORMAT_PARAGRAPH_TURBO,\\n@@ -746,6 +781,7 @@ const QUERIES = {\\n SUMMARIZE_TURBO,\\n TRANSCRIBE,\\n TRANSCRIBE_NEURALSPACE,\\n+ TRANSCRIBE_GEMINI,\\n TRANSLATE,\\n TRANSLATE_AZURE,\\n TRANSLATE_CONTEXT,\\n@@ -816,7 +852,7 @@ export {\\n EXPAND_STORY,\\n SYS_READ_MEMORY,\\n SYS_SAVE_MEMORY,\\n- RAG_START,\\n+ SYS_ENTITY_START,\\n SYS_ENTITY_CONTINUE,\\n SELECT_SERVICES,\\n SUMMARY,\\ndiff --git a/src/hooks/useStreamingMessages.js b/src/hooks/useStreamingMessages.js\\nnew file mode 100644\\nindex 0000000..d62d578\\n--- /dev/null\\n+++ b/src/hooks/useStreamingMessages.js\\n@@ -0,0 +1,361 @@\\n+import { useCallback, useRef, useState, useEffect } from \\\"react\\\";\\n+import { useSubscription } from \\\"@apollo/client\\\";\\n+import { SUBSCRIPTIONS } from \\\"../graphql\\\";\\n+import { processImageUrls } from \\\"../utils/imageUtils.mjs\\\";\\n+\\n+// Add utility function for chunking text\\n+const chunkText = (text, maxChunkSize = 9) => {\\n+ if (!text || text.length <= maxChunkSize) return [text];\\n+\\n+ const chunks = [];\\n+ let remaining = text;\\n+\\n+ while (remaining.length > 0) {\\n+ // Try to break at natural boundaries like spaces, periods, or commas\\n+ let chunkSize = Math.min(maxChunkSize, remaining.length);\\n+ let chunk = remaining.slice(0, chunkSize);\\n+\\n+ // If we're in the middle of a word and there's more text, try to break at a space\\n+ if (\\n+ remaining.length > chunkSize &&\\n+ !remaining[chunkSize].match(/[\\\\s.,!?]/)\\n+ ) {\\n+ const lastSpace = chunk.lastIndexOf(\\\" \\\");\\n+ if (lastSpace > 0) {\\n+ chunkSize = lastSpace + 1;\\n+ chunk = remaining.slice(0, chunkSize);\\n+ }\\n+ }\\n+\\n+ chunks.push(chunk);\\n+ remaining = remaining.slice(chunkSize);\\n+ }\\n+\\n+ return chunks;\\n+};\\n+\\n+export function useStreamingMessages({ chat, updateChatHook }) {\\n+ const streamingMessageRef = useRef(\\\"\\\");\\n+ const messageQueueRef = useRef([]);\\n+ const processingRef = useRef(false);\\n+ const accumulatedInfoRef = useRef({});\\n+ const updateTimeoutRef = useRef(null);\\n+ const pendingTitleUpdateRef = useRef(null);\\n+ const updatedTitleRef = useRef(null);\\n+ const transitionTimeoutRef = useRef(null);\\n+ const [subscriptionId, setSubscriptionId] = useState(null);\\n+ const [isStreaming, setIsStreaming] = useState(false);\\n+ const [streamingContent, setStreamingContent] = useState(\\\"\\\");\\n+ const [streamingTool, setStreamingTool] = useState(null);\\n+ const [isTitleUpdateInProgress, setTitleUpdateInProgress] = useState(false);\\n+ const completingMessageRef = useRef(false);\\n+ const chunkQueueRef = useRef([]);\\n+ const lastChunkTimeRef = useRef(0);\\n+ const CHUNK_INTERVAL = 4; // ~225fps for 3x faster rendering (was 13ms)\\n+\\n+ // Cleanup function for timeouts\\n+ useEffect(() => {\\n+ // Capture the ref value inside the effect\\n+ const timeoutRef = updateTimeoutRef;\\n+ const transitionRef = transitionTimeoutRef;\\n+ return () => {\\n+ if (timeoutRef.current) {\\n+ clearTimeout(timeoutRef.current);\\n+ }\\n+ if (transitionRef.current) {\\n+ clearTimeout(transitionRef.current);\\n+ }\\n+ };\\n+ }, []);\\n+\\n+ const clearStreamingState = useCallback(() => {\\n+ if (updateTimeoutRef.current) {\\n+ clearTimeout(updateTimeoutRef.current);\\n+ }\\n+ if (transitionTimeoutRef.current) {\\n+ clearTimeout(transitionTimeoutRef.current);\\n+ }\\n+ streamingMessageRef.current = \\\"\\\";\\n+ accumulatedInfoRef.current = {};\\n+ pendingTitleUpdateRef.current = null;\\n+ completingMessageRef.current = false;\\n+ setStreamingContent(\\\"\\\");\\n+ setSubscriptionId(null);\\n+ setIsStreaming(false);\\n+ setStreamingTool(null);\\n+ setTitleUpdateInProgress(false);\\n+ messageQueueRef.current = [];\\n+ processingRef.current = false;\\n+ chunkQueueRef.current = [];\\n+ }, []);\\n+\\n+ const completeMessage = useCallback(async () => {\\n+ if (\\n+ !chat?._id ||\\n+ !streamingMessageRef.current ||\\n+ completingMessageRef.current\\n+ )\\n+ return;\\n+\\n+ completingMessageRef.current = true;\\n+ const finalContent = streamingMessageRef.current; // Capture final content\\n+\\n+ // Process any image URLs in the final content\\n+ const processedContent = await processImageUrls(\\n+ finalContent,\\n+ window.location.origin,\\n+ );\\n+\\n+ const toolString = JSON.stringify({\\n+ ...accumulatedInfoRef.current,\\n+ citations: accumulatedInfoRef.current.citations || [],\\n+ });\\n+\\n+ const codeRequestId = accumulatedInfoRef.current.codeRequestId;\\n+\\n+ // Clear streaming state first\\n+ clearStreamingState();\\n+\\n+ try {\\n+ // Find the last streaming message index, if it exists\\n+ const messages = chat.messages || [];\\n+ const lastStreamingIndex = messages.findLastIndex(\\n+ (m) => m.isStreaming,\\n+ );\\n+\\n+ // If we found a streaming message, replace it; otherwise append\\n+ const updatedMessages = [...messages];\\n+ const newMessage = {\\n+ payload: processedContent,\\n+ tool: toolString,\\n+ sentTime: \\\"just now\\\",\\n+ direction: \\\"incoming\\\",\\n+ position: \\\"single\\\",\\n+ sender: \\\"labeeb\\\",\\n+ isStreaming: false,\\n+ };\\n+\\n+ if (lastStreamingIndex !== -1) {\\n+ updatedMessages[lastStreamingIndex] = newMessage;\\n+ } else {\\n+ updatedMessages.push(newMessage);\\n+ }\\n+\\n+ // Update chat with both the message and codeRequestId if we have coding tool info\\n+ const hasCodeRequest = !!codeRequestId;\\n+\\n+ const updatePayload = {\\n+ chatId: String(chat._id),\\n+ messages: updatedMessages,\\n+ isChatLoading: hasCodeRequest,\\n+ };\\n+\\n+ if (hasCodeRequest) {\\n+ updatePayload.codeRequestId = codeRequestId;\\n+ updatePayload.lastCodeRequestId = codeRequestId;\\n+ updatePayload.lastCodeRequestTime = new Date();\\n+ }\\n+\\n+ await updateChatHook.mutateAsync(updatePayload);\\n+ } catch (error) {\\n+ console.error(\\\"Failed to complete message:\\\", error);\\n+ } finally {\\n+ completingMessageRef.current = false;\\n+ }\\n+ }, [chat, updateChatHook, clearStreamingState]);\\n+\\n+ const stopStreaming = useCallback(async () => {\\n+ if (!chat?._id || !streamingMessageRef.current) return;\\n+ await completeMessage();\\n+ }, [chat, completeMessage]);\\n+\\n+ const updateStreamingContent = useCallback(async (newContent) => {\\n+ if (completingMessageRef.current) return;\\n+ streamingMessageRef.current = newContent;\\n+ // Don't process image URLs during streaming\\n+ setStreamingContent(newContent);\\n+ }, []);\\n+\\n+ const processChunkQueue = useCallback(async () => {\\n+ if (chunkQueueRef.current.length === 0 || completingMessageRef.current)\\n+ return;\\n+\\n+ const now = performance.now();\\n+ if (now - lastChunkTimeRef.current < CHUNK_INTERVAL) {\\n+ requestAnimationFrame(processChunkQueue);\\n+ return;\\n+ }\\n+\\n+ const chunk = chunkQueueRef.current.shift();\\n+ const newContent = streamingMessageRef.current + chunk;\\n+ await updateStreamingContent(newContent);\\n+ lastChunkTimeRef.current = now;\\n+\\n+ if (chunkQueueRef.current.length > 0) {\\n+ requestAnimationFrame(processChunkQueue);\\n+ }\\n+ }, [updateStreamingContent]);\\n+\\n+ const processMessageQueue = useCallback(async () => {\\n+ if (\\n+ processingRef.current ||\\n+ messageQueueRef.current.length === 0 ||\\n+ completingMessageRef.current\\n+ )\\n+ return;\\n+\\n+ processingRef.current = true;\\n+ const message = messageQueueRef.current.shift();\\n+\\n+ try {\\n+ const { progress, result, info } = message;\\n+\\n+ if (info) {\\n+ try {\\n+ // Skip processing if info starts with \\\"ERROR:\\\"\\n+ if (typeof info === \\\"string\\\" && info.startsWith(\\\"ERROR:\\\")) {\\n+ console.warn(\\\"Skipping error info:\\\", info);\\n+ return;\\n+ }\\n+\\n+ const parsedInfo =\\n+ typeof info === \\\"string\\\"\\n+ ? JSON.parse(info)\\n+ : typeof info === \\\"object\\\"\\n+ ? { ...info }\\n+ : {};\\n+\\n+ if (\\n+ parsedInfo.title &&\\n+ chat &&\\n+ !chat.titleSetByUser &&\\n+ chat.title !== parsedInfo.title &&\\n+ updatedTitleRef.current !== parsedInfo.title\\n+ ) {\\n+ // Mark the title as updated to prevent duplicate mutations\\n+ updatedTitleRef.current = parsedInfo.title;\\n+ if (!isTitleUpdateInProgress) {\\n+ setTitleUpdateInProgress(true);\\n+ updateChatHook\\n+ .mutateAsync({\\n+ chatId: String(chat._id),\\n+ title: parsedInfo.title,\\n+ })\\n+ .finally(() => {\\n+ setTitleUpdateInProgress(false);\\n+ });\\n+ }\\n+ }\\n+\\n+ // Store accumulated info\\n+ accumulatedInfoRef.current = {\\n+ ...accumulatedInfoRef.current,\\n+ ...parsedInfo,\\n+ };\\n+\\n+ // Always preserve citations array\\n+ accumulatedInfoRef.current.citations = [\\n+ ...(accumulatedInfoRef.current.citations || []),\\n+ ...(parsedInfo.citations || []),\\n+ ];\\n+ } catch (e) {\\n+ console.error(\\\"Failed to parse info block:\\\", e);\\n+ }\\n+ }\\n+\\n+ if (result) {\\n+ let content;\\n+ try {\\n+ const parsed = JSON.parse(result);\\n+ if (typeof parsed === \\\"string\\\") {\\n+ content = parsed;\\n+ } else if (parsed?.choices?.[0]?.delta?.content) {\\n+ content = parsed.choices[0].delta.content;\\n+ } else if (parsed?.content) {\\n+ content = parsed.content;\\n+ } else if (parsed?.message) {\\n+ content = parsed.message;\\n+ }\\n+ } catch {\\n+ content = result;\\n+ }\\n+\\n+ if (content) {\\n+ // Break content into smaller chunks and queue them\\n+ const chunks = chunkText(content);\\n+ chunkQueueRef.current.push(...chunks);\\n+\\n+ // Start processing chunks if not already processing\\n+ if (chunkQueueRef.current.length > 0) {\\n+ await processChunkQueue();\\n+ }\\n+ }\\n+ }\\n+\\n+ if (progress === 1) {\\n+ // Wait for all chunks to be processed before completing\\n+ const waitForChunks = async () => {\\n+ if (chunkQueueRef.current.length > 0) {\\n+ await new Promise((resolve) => setTimeout(resolve, 10));\\n+ await waitForChunks();\\n+ return;\\n+ }\\n+ await completeMessage();\\n+ };\\n+ await waitForChunks();\\n+ }\\n+ } catch (e) {\\n+ console.error(\\\"Failed to process subscription data:\\\", e);\\n+ }\\n+\\n+ processingRef.current = false;\\n+\\n+ // Schedule next message processing\\n+ if (\\n+ messageQueueRef.current.length > 0 &&\\n+ !completingMessageRef.current\\n+ ) {\\n+ requestAnimationFrame(async () => await processMessageQueue());\\n+ }\\n+ }, [\\n+ chat,\\n+ completeMessage,\\n+ isTitleUpdateInProgress,\\n+ updateChatHook,\\n+ processChunkQueue,\\n+ ]);\\n+\\n+ useSubscription(SUBSCRIPTIONS.REQUEST_PROGRESS, {\\n+ variables: { requestIds: [subscriptionId] },\\n+ skip: !subscriptionId,\\n+ onData: ({ data }) => {\\n+ if (!data?.data || completingMessageRef.current) return;\\n+\\n+ const progress = data.data.requestProgress?.progress;\\n+ const result = data.data.requestProgress?.data;\\n+ const info = data.data.requestProgress?.info;\\n+\\n+ if (result || progress === 1 || info) {\\n+ messageQueueRef.current.push({\\n+ progress,\\n+ result: result || null,\\n+ info,\\n+ });\\n+ if (!processingRef.current) {\\n+ requestAnimationFrame(() => processMessageQueue());\\n+ }\\n+ }\\n+ },\\n+ });\\n+\\n+ return {\\n+ isStreaming,\\n+ streamingContent,\\n+ stopStreaming,\\n+ setIsStreaming,\\n+ setSubscriptionId,\\n+ streamingMessageRef,\\n+ clearStreamingState,\\n+ streamingTool,\\n+ };\\n+}\\ndiff --git a/src/layout/ChatNavigationItem.js b/src/layout/ChatNavigationItem.js\\nindex 475beb6..0148c4b 100644\\n--- a/src/layout/ChatNavigationItem.js\\n+++ b/src/layout/ChatNavigationItem.js\\n@@ -35,7 +35,9 @@ const ChatNavigationItem = ({\\n if (subItem.href && editingId !== subItem.key) {\\n setEditingId(null);\\n router.push(subItem.href);\\n- setActiveChatId.mutateAsync(subItem.key);\\n+ setActiveChatId.mutateAsync(subItem.key).catch((error) => {\\n+ console.error(\\\"Error setting active chat ID:\\\", error);\\n+ });\\n }\\n }}\\n >\\n@@ -87,7 +89,7 @@ const ChatNavigationItem = ({\\n </>\\n ) : (\\n <>\\n- <div className=\\\"basis-3\\\">\\n+ <div className=\\\"basis-3 hidden sm:block\\\">\\n <EditIcon\\n className=\\\"h-3 w-3 text-gray-400 hover:text-gray-600 cursor-pointer invisible group-hover:visible\\\"\\n onClick={(e) => {\\n@@ -104,7 +106,7 @@ const ChatNavigationItem = ({\\n )}\\n </div>\\n {editingId !== subItem.key && (\\n- <div className=\\\"basis-3 text-end\\\">\\n+ <div className=\\\"basis-3 text-end hidden sm:block\\\">\\n <TrashIcon\\n className=\\\"h-3 w-3 text-gray-400 hover:text-gray-600 cursor-pointer invisible group-hover:visible hover:text-red-600\\\"\\n aria-hidden=\\\"true\\\"\\ndiff --git a/src/layout/Layout.js b/src/layout/Layout.js\\nindex c080f58..c5e778e 100644\\n--- a/src/layout/Layout.js\\n+++ b/src/layout/Layout.js\\n@@ -1,24 +1,24 @@\\n \\\"use client\\\";\\n import { Dialog, Transition } from \\\"@headlessui/react\\\";\\n import { Bars3Icon, XMarkIcon } from \\\"@heroicons/react/24/outline\\\";\\n+import { MessageCircle } from \\\"lucide-react\\\";\\n+import { usePathname } from \\\"next/navigation\\\";\\n import { Fragment, useContext, useEffect, useRef, useState } from \\\"react\\\";\\n-import { useTranslation } from \\\"react-i18next\\\";\\n-import { IoIosChatbubbles } from \\\"react-icons/io\\\";\\n import { useDispatch, useSelector } from \\\"react-redux\\\";\\n+import { Flip, ToastContainer } from \\\"react-toastify\\\";\\n+import \\\"react-toastify/dist/ReactToastify.css\\\";\\n import { AuthContext } from \\\"../App\\\";\\n-import { setChatBoxPosition } from \\\"../stores/chatSlice\\\";\\n-import Footer from \\\"./Footer\\\";\\n-import ProfileDropdown from \\\"./ProfileDropdown\\\";\\n-import UserOptions from \\\"../components/UserOptions\\\";\\n-import Sidebar from \\\"./Sidebar\\\";\\n-import { usePathname } from \\\"next/navigation\\\";\\n import ChatBox from \\\"../components/chat/ChatBox\\\";\\n+import NotificationButton from \\\"../components/notifications/NotificationButton\\\";\\n import Tos from \\\"../components/Tos\\\";\\n-import { ToastContainer, Flip } from \\\"react-toastify\\\";\\n-import \\\"react-toastify/dist/ReactToastify.css\\\";\\n-import { ThemeContext } from \\\"../contexts/ThemeProvider\\\";\\n+import UserOptions from \\\"../components/UserOptions\\\";\\n import { LanguageContext } from \\\"../contexts/LanguageProvider\\\";\\n import { ProgressProvider } from \\\"../contexts/ProgressContext\\\";\\n+import { ThemeContext } from \\\"../contexts/ThemeProvider\\\";\\n+import { setChatBoxPosition } from \\\"../stores/chatSlice\\\";\\n+import Footer from \\\"./Footer\\\";\\n+import ProfileDropdown from \\\"./ProfileDropdown\\\";\\n+import Sidebar from \\\"./Sidebar\\\";\\n \\n export default function Layout({ children }) {\\n const [showOptions, setShowOptions] = useState(false);\\n@@ -26,7 +26,6 @@ export default function Layout({ children }) {\\n const [showTos, setShowTos] = useState(false);\\n const statePosition = useSelector((state) => state.chat?.chatBox?.position);\\n const dispatch = useDispatch();\\n- const { t } = useTranslation();\\n const { user } = useContext(AuthContext);\\n const pathname = usePathname();\\n const { theme } = useContext(ThemeContext);\\n@@ -132,7 +131,7 @@ export default function Layout({ children }) {\\n </div>\\n \\n <div className=\\\"lg:ps-56 overflow-hidden\\\">\\n- <div className=\\\"sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8\\\">\\n+ <div className=\\\"sticky top-0 z-40 flex h-12 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-2 shadow-sm sm:gap-x-6 sm:px-3 lg:px-4\\\">\\n <button\\n type=\\\"button\\\"\\n className=\\\"-m-2.5 p-2.5 text-gray-700 lg:hidden\\\"\\n@@ -148,21 +147,50 @@ export default function Layout({ children }) {\\n aria-hidden=\\\"true\\\"\\n />\\n \\n- <div className=\\\"flex flex-1 items-center gap-x-2 justify-end lg:gap-x-4\\\">\\n- <div className=\\\"hidden sm:block\\\">\\n- <button\\n- className=\\\"lb-primary\\\"\\n- disabled={/^\\\\/chat(\\\\/|$)/.test(pathname)}\\n- onClick={() => {\\n- dispatch(\\n- setChatBoxPosition({\\n- position: \\\"docked\\\",\\n- }),\\n- );\\n- }}\\n- >\\n- <IoIosChatbubbles /> {t(\\\"Chat\\\")}\\n- </button>\\n+ <div className=\\\"flex flex-1 items-center gap-x-3 justify-end \\\">\\n+ <div className=\\\"flex gap-3\\\">\\n+ {!pathname?.includes(\\\"/chat\\\") && (\\n+ <div className=\\\"hidden sm:block flex items-center\\\">\\n+ <button\\n+ disabled={/^\\\\/chat(\\\\/|$)/.test(\\n+ pathname,\\n+ )}\\n+ onClick={() => {\\n+ if (\\n+ statePosition === \\\"docked\\\"\\n+ ) {\\n+ dispatch(\\n+ setChatBoxPosition({\\n+ position: \\\"closed\\\",\\n+ }),\\n+ );\\n+ } else {\\n+ dispatch(\\n+ setChatBoxPosition({\\n+ position: \\\"docked\\\",\\n+ }),\\n+ );\\n+ }\\n+ }}\\n+ className=\\\"relative mt-1\\\"\\n+ >\\n+ <MessageCircle\\n+ fill={\\n+ statePosition ===\\n+ \\\"docked\\\" ||\\n+ pathname === \\\"/chat\\\"\\n+ ? \\\"#0284c7\\\"\\n+ : \\\"none\\\"\\n+ }\\n+ stroke=\\\"#0284c7\\\"\\n+ className=\\\"h-5 w-5 text-gray-500 hover:text-gray-700\\\"\\n+ />\\n+ </button>\\n+ </div>\\n+ )}\\n+ <div className=\\\"flex items-center\\\">\\n+ <NotificationButton />\\n+ </div>\\n </div>\\n <div>\\n <ProfileDropdown\\n@@ -181,8 +209,8 @@ export default function Layout({ children }) {\\n ref={contentRef}\\n >\\n <div\\n- className={`${\\\"grow\\\"} bg-white dark:border-gray-200 rounded-md border p-4 lg:p-6 overflow-auto`}\\n- style={{ height: \\\"calc(100vh - 120px)\\\" }}\\n+ className={`grow bg-white dark:border-gray-200 rounded-md border p-3 lg:p-4 lg:pb-3 overflow-auto`}\\n+ style={{ height: \\\"calc(100vh - 105px)\\\" }}\\n >\\n {showOptions && (\\n <UserOptions\\n@@ -197,7 +225,7 @@ export default function Layout({ children }) {\\n {children}\\n </div>\\n {showChatbox && (\\n- <div className=\\\"hidden sm:block basis-[302px] h-[calc(100vh-120px)]\\\">\\n+ <div className=\\\"hidden sm:block basis-[302px] h-[calc(100vh-105px)]\\\">\\n <ChatBox />\\n </div>\\n )}\\ndiff --git a/src/layout/Sidebar.js b/src/layout/Sidebar.js\\nindex aa13685..94c6880 100644\\n--- a/src/layout/Sidebar.js\\n+++ b/src/layout/Sidebar.js\\n@@ -87,11 +87,25 @@ export default React.forwardRef(function Sidebar(_, ref) {\\n return {\\n ...item,\\n children: items.map((chat) => ({\\n- name:\\n- chat?.title && chat.title !== \\\"New Chat\\\"\\n- ? chat.title\\n- : (chat?.messages && chat?.messages[0]?.payload) ||\\n- t(\\\"New Chat\\\"),\\n+ name: (() => {\\n+ // If there's a custom title, use it\\n+ if (chat?.title && chat.title !== \\\"New Chat\\\") {\\n+ return chat.title;\\n+ }\\n+\\n+ // If there's a firstMessage property (from backend), use it\\n+ if (chat?.firstMessage?.payload) {\\n+ return chat.firstMessage.payload;\\n+ }\\n+\\n+ // If there's a message in the messages array, use it\\n+ if (chat?.messages && chat?.messages[0]?.payload) {\\n+ return chat.messages[0].payload;\\n+ }\\n+\\n+ // Otherwise use \\\"New Chat\\\"\\n+ return t(\\\"New Chat\\\");\\n+ })(),\\n href: chat._id ? `/chat/${chat._id}` : ``,\\n key: chat._id,\\n })),\\ndiff --git a/src/utils/__tests__/urlUtils.test.js b/src/utils/__tests__/urlUtils.test.js\\nnew file mode 100644\\nindex 0000000..2a762bc\\n--- /dev/null\\n+++ b/src/utils/__tests__/urlUtils.test.js\\n@@ -0,0 +1,118 @@\\n+import {\\n+ isYoutubeUrl,\\n+ getYoutubeEmbedUrl,\\n+ getYoutubeVideoId,\\n+} from \\\"../urlUtils\\\";\\n+\\n+describe(\\\"urlUtils\\\", () => {\\n+ describe(\\\"isYoutubeUrl\\\", () => {\\n+ it(\\\"should return true for valid YouTube URLs\\\", () => {\\n+ const validUrls = [\\n+ \\\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\\\",\\n+ \\\"https://youtube.com/watch?v=dQw4w9WgXcQ\\\",\\n+ \\\"https://youtu.be/dQw4w9WgXcQ\\\",\\n+ \\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\",\\n+ ];\\n+\\n+ validUrls.forEach((url) => {\\n+ expect(isYoutubeUrl(url)).toBe(true);\\n+ });\\n+ });\\n+\\n+ it(\\\"should return false for invalid YouTube URLs\\\", () => {\\n+ const invalidUrls = [\\n+ \\\"https://www.youtube.com\\\",\\n+ \\\"https://youtube.com\\\",\\n+ \\\"https://youtu.be\\\",\\n+ \\\"https://www.youtube.com/embed/\\\",\\n+ \\\"https://www.youtube.com/watch\\\",\\n+ \\\"https://www.youtube.com/watch?\\\",\\n+ \\\"https://www.otherdomain.com/watch?v=dQw4w9WgXcQ\\\",\\n+ \\\"not a url\\\",\\n+ \\\"\\\",\\n+ ];\\n+\\n+ invalidUrls.forEach((url) => {\\n+ expect(isYoutubeUrl(url)).toBe(false);\\n+ });\\n+ });\\n+ });\\n+\\n+ describe(\\\"getYoutubeEmbedUrl\\\", () => {\\n+ it(\\\"should convert standard YouTube URLs to embed format\\\", () => {\\n+ expect(\\n+ getYoutubeEmbedUrl(\\n+ \\\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\\\",\\n+ ),\\n+ ).toBe(\\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\");\\n+\\n+ expect(\\n+ getYoutubeEmbedUrl(\\\"https://youtube.com/watch?v=dQw4w9WgXcQ\\\"),\\n+ ).toBe(\\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\");\\n+ });\\n+\\n+ it(\\\"should handle shortened youtu.be URLs\\\", () => {\\n+ expect(getYoutubeEmbedUrl(\\\"https://youtu.be/dQw4w9WgXcQ\\\")).toBe(\\n+ \\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\",\\n+ );\\n+ });\\n+\\n+ it(\\\"should handle embed URLs\\\", () => {\\n+ const embedUrl = \\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\";\\n+ expect(getYoutubeEmbedUrl(embedUrl)).toBe(embedUrl);\\n+ });\\n+\\n+ it(\\\"should return null for invalid URLs\\\", () => {\\n+ const invalidUrls = [\\n+ \\\"https://www.youtube.com\\\",\\n+ \\\"https://youtube.com\\\",\\n+ \\\"https://youtu.be\\\",\\n+ \\\"not a url\\\",\\n+ \\\"\\\",\\n+ ];\\n+\\n+ invalidUrls.forEach((url) => {\\n+ expect(getYoutubeEmbedUrl(url)).toBeNull();\\n+ });\\n+ });\\n+ });\\n+\\n+ describe(\\\"getYoutubeVideoId\\\", () => {\\n+ it(\\\"should extract video ID from various YouTube URL formats\\\", () => {\\n+ const testCases = [\\n+ {\\n+ input: \\\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\\\",\\n+ expected: \\\"dQw4w9WgXcQ\\\",\\n+ },\\n+ {\\n+ input: \\\"https://youtu.be/dQw4w9WgXcQ\\\",\\n+ expected: \\\"dQw4w9WgXcQ\\\",\\n+ },\\n+ {\\n+ input: \\\"https://www.youtube.com/embed/dQw4w9WgXcQ\\\",\\n+ expected: \\\"dQw4w9WgXcQ\\\",\\n+ },\\n+ ];\\n+\\n+ testCases.forEach(({ input, expected }) => {\\n+ expect(getYoutubeVideoId(input)).toBe(expected);\\n+ });\\n+ });\\n+\\n+ it(\\\"should throw an error with a specific message for invalid URLs\\\", () => {\\n+ const invalidUrls = [\\n+ \\\"https://www.youtube.com\\\",\\n+ \\\"https://youtube.com\\\",\\n+ \\\"https://youtu.be\\\",\\n+ \\\"not a url\\\",\\n+ \\\"\\\",\\n+ ];\\n+\\n+ invalidUrls.forEach((url) => {\\n+ expect(() => getYoutubeVideoId(url)).toThrow(\\n+ \\\"Invalid YouTube URL\\\",\\n+ );\\n+ });\\n+ });\\n+ });\\n+});\\ndiff --git a/src/utils/imageUtils.js b/src/utils/imageUtils.mjs\\nsimilarity index 60%\\nrename from src/utils/imageUtils.js\\nrename to src/utils/imageUtils.mjs\\nindex d98cb92..8eff7ca 100644\\n--- a/src/utils/imageUtils.js\\n+++ b/src/utils/imageUtils.mjs\\n@@ -31,6 +31,48 @@ const isMediaHelperConfigured = () => {\\n }\\n };\\n \\n+// Create a WeakMap to store stable IDs for image nodes\\n+const imageNodeIds = new WeakMap();\\n+\\n+// Add a Map to cache IDs by URL to prevent duplicates\\n+const imageUrlToId = new Map();\\n+\\n+// Add a Map to track temporary to permanent URL mappings\\n+const tempToPermanentUrlMap = new Map();\\n+\\n+let nextImageId = 1;\\n+\\n+function getStableImageId(src, node = null) {\\n+ // Check if this is a temporary URL that has a permanent version\\n+ const permanentUrl = tempToPermanentUrlMap.get(src);\\n+ const urlToUse = permanentUrl || src;\\n+\\n+ // First check if we have an ID for this URL\\n+ let stableId = imageUrlToId.get(urlToUse);\\n+ if (!stableId) {\\n+ // If we have a node, try to get its ID\\n+ if (node && typeof node === \\\"object\\\") {\\n+ stableId = imageNodeIds.get(node);\\n+ }\\n+ // If still no ID, generate a new one\\n+ if (!stableId) {\\n+ stableId = `img-${nextImageId++}`;\\n+ // Only store in WeakMap if we have a valid node\\n+ if (node && typeof node === \\\"object\\\") {\\n+ imageNodeIds.set(node, stableId);\\n+ }\\n+ }\\n+ // Cache the ID for this URL\\n+ imageUrlToId.set(urlToUse, stableId);\\n+\\n+ // If this is a temporary URL, also store the ID for the permanent URL\\n+ if (permanentUrl) {\\n+ imageUrlToId.set(permanentUrl, stableId);\\n+ }\\n+ }\\n+ return stableId;\\n+}\\n+\\n // Common image extensions that we want to process\\n const IMAGE_EXTENSIONS = [\\\".jpg\\\", \\\".jpeg\\\", \\\".png\\\", \\\".webp\\\", \\\".gif\\\"];\\n \\n@@ -51,6 +93,16 @@ function isImageUrl(url) {\\n }\\n }\\n \\n+// Preload an image to ensure it's in the browser cache\\n+function preloadImage(url) {\\n+ return new Promise((resolve, reject) => {\\n+ const img = new Image();\\n+ img.onload = () => resolve(url);\\n+ img.onerror = (err) => reject(err);\\n+ img.src = url;\\n+ });\\n+}\\n+\\n const MEDIA_HELPER_TIMEOUT_MS = 5000; // 5 seconds timeout\\n \\n /**\\n@@ -90,6 +142,10 @@ async function processImageUrls(message, serverUrl) {\\n }\\n }\\n \\n+ // Create a map of replacements to apply\\n+ const replacements = [];\\n+ const preloadPromises = [];\\n+\\n for (const { url, description, fullMatch } of matches) {\\n if (isImageUrl(url)) {\\n try {\\n@@ -114,16 +170,30 @@ async function processImageUrls(message, serverUrl) {\\n \\n if (uploadResponse.ok) {\\n const data = await uploadResponse.json();\\n+\\n+ // Store the mapping from temporary to permanent URL\\n+ tempToPermanentUrlMap.set(url, data.url);\\n+\\n+ // Preload the permanent image and track the promise\\n+ preloadPromises.push(\\n+ preloadImage(data.url).catch(() => {}),\\n+ );\\n+\\n+ // Store the replacement to apply\\n if (description !== null) {\\n // Replace markdown image with preserved description\\n- message = message.replace(\\n- fullMatch,\\n- ``,\\n- );\\n+ replacements.push({\\n+ original: fullMatch,\\n+ replacement: ``,\\n+ });\\n } else {\\n // Replace regular URL\\n- message = message.replace(url, data.url);\\n+ replacements.push({\\n+ original: url,\\n+ replacement: data.url,\\n+ });\\n }\\n+\\n console.log(\\n \\\"Replaced temporary image URL with permanent URL:\\\",\\n data.url,\\n@@ -143,9 +213,30 @@ async function processImageUrls(message, serverUrl) {\\n }\\n }\\n }\\n- return message;\\n+\\n+ // Wait for all images to preload (with a reasonable timeout)\\n+ if (preloadPromises.length > 0) {\\n+ await Promise.race([\\n+ Promise.all(preloadPromises),\\n+ new Promise((resolve) => setTimeout(resolve, 2000)),\\n+ ]);\\n+ }\\n+\\n+ // Apply all replacements\\n+ let processedMessage = message;\\n+ for (const { original, replacement } of replacements) {\\n+ processedMessage = processedMessage.replace(original, replacement);\\n+ }\\n+\\n+ return processedMessage;\\n }\\n \\n-// Export in a way that works for both CommonJS and ES modules\\n-module.exports = { processImageUrls };\\n-module.exports.default = { processImageUrls };\\n+export {\\n+ getStableImageId,\\n+ imageNodeIds,\\n+ imageUrlToId,\\n+ tempToPermanentUrlMap,\\n+ IMAGE_EXTENSIONS,\\n+ isImageUrl,\\n+ processImageUrls,\\n+};\\ndiff --git a/src/utils/mediaUploadUtils.js b/src/utils/mediaUploadUtils.js\\nnew file mode 100644\\nindex 0000000..3cf8e8a\\n--- /dev/null\\n+++ b/src/utils/mediaUploadUtils.js\\n@@ -0,0 +1,62 @@\\n+import config from \\\"../../config\\\";\\n+import { getVideoDurationFromUrl } from \\\"./mediaUtils\\\";\\n+\\n+export const uploadVideoFromUrl = async (\\n+ videoUrl,\\n+ serverUrl,\\n+ setUploadProgress,\\n+) => {\\n+ let videoDuration;\\n+ try {\\n+ videoDuration = await getVideoDurationFromUrl(videoUrl);\\n+ } catch (error) {\\n+ console.warn(\\\"Failed to get video duration:\\\", error);\\n+ }\\n+\\n+ try {\\n+ let progressInterval;\\n+ if (videoDuration) {\\n+ const estimatedTime = Math.max(5000, videoDuration * 1000);\\n+ const updateInterval = 100;\\n+ const steps = estimatedTime / updateInterval;\\n+\\n+ progressInterval = setInterval(() => {\\n+ setUploadProgress((prev) => {\\n+ const increment = 100 / steps;\\n+ return Math.min(95, prev + increment);\\n+ });\\n+ }, updateInterval);\\n+ }\\n+\\n+ const response = await fetch(\\n+ `${config.endpoints.mediaHelper(serverUrl)}?fetch=${encodeURIComponent(videoUrl)}`,\\n+ {\\n+ method: \\\"GET\\\",\\n+ headers: {\\n+ \\\"Content-Type\\\": \\\"application/json\\\",\\n+ },\\n+ },\\n+ );\\n+\\n+ if (progressInterval) {\\n+ clearInterval(progressInterval);\\n+ setUploadProgress(100);\\n+ }\\n+\\n+ if (!response.ok) {\\n+ const errorBody = await response.text();\\n+ throw new Error(\\n+ `Upload failed: ${response.statusText}. Response body: ${errorBody}`,\\n+ );\\n+ }\\n+\\n+ const data = await response.json();\\n+ return {\\n+ url: data.url || \\\"\\\",\\n+ gcs: data.gcs || \\\"\\\",\\n+ };\\n+ } catch (error) {\\n+ console.error(\\\"Error uploading video from URL:\\\", error);\\n+ throw error;\\n+ }\\n+};\\ndiff --git a/src/utils/mediaUtils.js b/src/utils/mediaUtils.js\\nindex ce472f7..b41c3e5 100644\\n--- a/src/utils/mediaUtils.js\\n+++ b/src/utils/mediaUtils.js\\n@@ -1,4 +1,6 @@\\n import xxhash from \\\"xxhash-wasm\\\";\\n+import mime from \\\"mime-types\\\";\\n+import { isYoutubeUrl } from \\\"./urlUtils\\\";\\n \\n let xxhashInstance = null;\\n \\n@@ -10,6 +12,187 @@ async function getXXHashInstance() {\\n return xxhashInstance;\\n }\\n \\n+// File type definitions\\n+export const DOC_EXTENSIONS = [\\n+ \\\".json\\\",\\n+ \\\".csv\\\",\\n+ \\\".md\\\",\\n+ \\\".xml\\\",\\n+ \\\".js\\\",\\n+ \\\".html\\\",\\n+ \\\".css\\\",\\n+ \\\".docx\\\",\\n+ \\\".xlsx\\\",\\n+ \\\".xls\\\",\\n+ \\\".doc\\\",\\n+];\\n+\\n+export const IMAGE_EXTENSIONS = [\\n+ \\\".jpg\\\",\\n+ \\\".jpeg\\\",\\n+ \\\".png\\\",\\n+ \\\".webp\\\",\\n+ \\\".heic\\\",\\n+ \\\".heif\\\",\\n+ \\\".pdf\\\",\\n+ \\\".txt\\\",\\n+];\\n+\\n+export const VIDEO_EXTENSIONS = [\\n+ \\\".mp4\\\",\\n+ \\\".mpeg\\\",\\n+ \\\".mov\\\",\\n+ \\\".avi\\\",\\n+ \\\".flv\\\",\\n+ \\\".mpg\\\",\\n+ \\\".webm\\\",\\n+ \\\".wmv\\\",\\n+ \\\".3gp\\\",\\n+];\\n+\\n+export const AUDIO_EXTENSIONS = [\\n+ \\\".wav\\\",\\n+ \\\".mp3\\\",\\n+ \\\".m4a\\\",\\n+ \\\".aac\\\",\\n+ \\\".ogg\\\",\\n+ \\\".flac\\\",\\n+];\\n+\\n+export const DOC_MIME_TYPES = DOC_EXTENSIONS.map((ext) => mime.lookup(ext));\\n+\\n+export const MEDIA_MIME_TYPES = [\\n+ // Images\\n+ \\\"image/png\\\",\\n+ \\\"image/jpeg\\\",\\n+ \\\"image/webp\\\",\\n+ \\\"image/heic\\\",\\n+ \\\"image/heif\\\",\\n+ // Videos\\n+ \\\"video/mp4\\\",\\n+ \\\"video/mpeg\\\",\\n+ \\\"video/mov\\\",\\n+ \\\"video/quicktime\\\",\\n+ \\\"video/avi\\\",\\n+ \\\"video/x-flv\\\",\\n+ \\\"video/mpg\\\",\\n+ \\\"video/webm\\\",\\n+ \\\"video/wmv\\\",\\n+ \\\"video/3gpp\\\",\\n+ \\\"video/m4v\\\",\\n+ \\\"video/youtube\\\",\\n+ // Audio\\n+ \\\"audio/wav\\\",\\n+ \\\"audio/mpeg\\\",\\n+ \\\"audio/aac\\\",\\n+ \\\"audio/ogg\\\",\\n+ \\\"audio/flac\\\",\\n+ \\\"audio/m4a\\\",\\n+ \\\"audio/mp3\\\",\\n+ \\\"audio/mp4\\\",\\n+ \\\"audio/x-m4a\\\", // Common browser MIME type for .m4a files\\n+ // PDF\\n+ \\\"application/pdf\\\",\\n+ // Text\\n+ \\\"text/plain\\\",\\n+];\\n+\\n+export const ACCEPTED_FILE_TYPES = [...DOC_MIME_TYPES, ...MEDIA_MIME_TYPES];\\n+\\n+// File type utilities\\n+export function getExtension(url) {\\n+ if (!url) return \\\"\\\";\\n+ const filename = url.split(\\\"?\\\")[0].split(\\\"#\\\")[0];\\n+ const lastDotIndex = filename.lastIndexOf(\\\".\\\");\\n+ return lastDotIndex > 0 ? filename.slice(lastDotIndex).toLowerCase() : \\\"\\\";\\n+}\\n+\\n+export function isDocumentUrl(url) {\\n+ const urlExt = getExtension(url);\\n+ return DOC_EXTENSIONS.includes(urlExt);\\n+}\\n+\\n+export function isImageUrl(url) {\\n+ const urlExt = getExtension(url);\\n+ const mimeType = mime.contentType(urlExt);\\n+ return (\\n+ IMAGE_EXTENSIONS.includes(urlExt) &&\\n+ (mimeType.startsWith(\\\"image/\\\") ||\\n+ mimeType === \\\"application/pdf\\\" ||\\n+ mimeType.startsWith(\\\"text/plain\\\"))\\n+ );\\n+}\\n+\\n+export function isVideoUrl(url) {\\n+ const urlExt = getExtension(url);\\n+ const mimeType = mime.contentType(urlExt);\\n+ return VIDEO_EXTENSIONS.includes(urlExt) && mimeType.startsWith(\\\"video/\\\");\\n+}\\n+\\n+export function isAudioUrl(url) {\\n+ const urlExt = getExtension(url);\\n+ const mimeType = mime.contentType(urlExt);\\n+ return AUDIO_EXTENSIONS.includes(urlExt) && mimeType.startsWith(\\\"audio/\\\");\\n+}\\n+\\n+export function isMediaUrl(url) {\\n+ return isImageUrl(url) || isVideoUrl(url) || isAudioUrl(url);\\n+}\\n+\\n+export function getYoutubeVideoId(url) {\\n+ try {\\n+ const urlObj = new URL(url);\\n+ // Handle youtu.be URLs\\n+ if (urlObj.hostname === \\\"youtu.be\\\") {\\n+ return urlObj.pathname.substring(1).split(\\\"?\\\")[0];\\n+ }\\n+ // Handle youtube.com URLs\\n+ if (\\n+ urlObj.hostname === \\\"youtube.com\\\" ||\\n+ urlObj.hostname === \\\"www.youtube.com\\\"\\n+ ) {\\n+ return urlObj.searchParams.get(\\\"v\\\");\\n+ }\\n+ return null;\\n+ } catch (err) {\\n+ return null;\\n+ }\\n+}\\n+\\n+// Extracts the filename from a URL\\n+export function getFilename(url) {\\n+ try {\\n+ // Special handling for YouTube URLs\\n+ if (isYoutubeUrl(url)) {\\n+ const videoId = getYoutubeVideoId(url);\\n+ return videoId ? `youtube-video-${videoId}` : \\\"youtube-video\\\";\\n+ }\\n+\\n+ // Create a URL object to handle parsing\\n+ const urlObject = new URL(url);\\n+\\n+ // Get the pathname and remove leading/trailing slashes\\n+ const path = urlObject.pathname.replace(/^\\\\/|\\\\/$/g, \\\"\\\");\\n+\\n+ // Get the last part of the path (filename)\\n+ const fullFilename = path.split(\\\"/\\\").pop() || \\\"\\\";\\n+\\n+ // Decode the filename to handle URL encoding\\n+ const decodedFilename = decodeURIComponent(fullFilename);\\n+\\n+ // Split by underscore and remove the first part if it exists\\n+ const parts = decodedFilename.split(\\\"_\\\");\\n+ const relevantParts = parts.length > 1 ? parts.slice(1) : parts;\\n+\\n+ // Join the parts back together\\n+ return relevantParts.join(\\\"_\\\");\\n+ } catch (error) {\\n+ console.error(\\\"Error parsing URL:\\\", error);\\n+ return \\\"\\\";\\n+ }\\n+}\\n+\\n+// Media file utilities\\n export async function hashMediaFile(file) {\\n const hasher = await getXXHashInstance();\\n const xxh64 = hasher.create64();\\n@@ -38,3 +221,13 @@ export const getVideoDuration = (file) => {\\n video.src = URL.createObjectURL(file);\\n });\\n };\\n+\\n+export const getVideoDurationFromUrl = (url) => {\\n+ return new Promise((resolve, reject) => {\\n+ const video = document.createElement(\\\"video\\\");\\n+ video.preload = \\\"metadata\\\";\\n+ video.src = url;\\n+ video.onloadedmetadata = () => resolve(video.duration);\\n+ video.onerror = reject;\\n+ });\\n+};\\ndiff --git a/src/utils/urlUtils.js b/src/utils/urlUtils.js\\nnew file mode 100644\\nindex 0000000..5d2096f\\n--- /dev/null\\n+++ b/src/utils/urlUtils.js\\n@@ -0,0 +1,86 @@\\n+/**\\n+ * Checks if a given URL is a valid YouTube URL\\n+ * Supports standard youtube.com, shortened youtu.be, embed URLs, and shorts URLs\\n+ *\\n+ * @param {string} url - The URL to check\\n+ * @returns {boolean} - True if URL is a valid YouTube URL\\n+ */\\n+export function isYoutubeUrl(url) {\\n+ try {\\n+ const urlObj = new URL(url);\\n+\\n+ // Check for standard youtube.com domains\\n+ if (\\n+ urlObj.hostname === \\\"youtube.com\\\" ||\\n+ urlObj.hostname === \\\"www.youtube.com\\\"\\n+ ) {\\n+ // For standard watch URLs, verify they have a video ID\\n+ if (urlObj.pathname === \\\"/watch\\\") {\\n+ return !!urlObj.searchParams.get(\\\"v\\\");\\n+ }\\n+ // For embed URLs, verify they have a video ID in the path\\n+ if (urlObj.pathname.startsWith(\\\"/embed/\\\")) {\\n+ return urlObj.pathname.length > 7; // '/embed/' is 7 chars\\n+ }\\n+ // For shorts URLs, verify they have a video ID in the path\\n+ if (urlObj.pathname.startsWith(\\\"/shorts/\\\")) {\\n+ return urlObj.pathname.length > 8; // '/shorts/' is 8 chars\\n+ }\\n+ return false;\\n+ }\\n+\\n+ // Check for shortened youtu.be domain\\n+ if (urlObj.hostname === \\\"youtu.be\\\") {\\n+ // Verify there's a video ID in the path\\n+ return urlObj.pathname.length > 1; // '/' is 1 char\\n+ }\\n+\\n+ return false;\\n+ } catch (err) {\\n+ return false;\\n+ }\\n+}\\n+\\n+/**\\n+ * Converts a YouTube URL to its embed format\\n+ * Supports standard youtube.com, shortened youtu.be URLs\\n+ *\\n+ * @param {string} url - The YouTube URL to convert\\n+ * @returns {string|null} - The embed URL if valid, null if invalid\\n+ */\\n+export function getYoutubeEmbedUrl(url) {\\n+ try {\\n+ const urlObj = new URL(url);\\n+ let videoId = null;\\n+\\n+ // Handle standard youtube.com URLs\\n+ if (\\n+ urlObj.hostname === \\\"youtube.com\\\" ||\\n+ urlObj.hostname === \\\"www.youtube.com\\\"\\n+ ) {\\n+ if (urlObj.pathname === \\\"/watch\\\") {\\n+ videoId = urlObj.searchParams.get(\\\"v\\\");\\n+ } else if (urlObj.pathname.startsWith(\\\"/embed/\\\")) {\\n+ videoId = urlObj.pathname.slice(7); // Remove '/embed/'\\n+ }\\n+ }\\n+\\n+ // Handle shortened youtu.be URLs\\n+ else if (urlObj.hostname === \\\"youtu.be\\\") {\\n+ videoId = urlObj.pathname.slice(1); // Remove leading '/'\\n+ }\\n+\\n+ return videoId ? `https://www.youtube.com/embed/${videoId}` : null;\\n+ } catch (err) {\\n+ return null;\\n+ }\\n+}\\n+\\n+export function getYoutubeVideoId(url) {\\n+ const embedUrl = getYoutubeEmbedUrl(url);\\n+ if (!embedUrl) {\\n+ throw new Error(\\\"Invalid YouTube URL\\\");\\n+ }\\n+ const urlObj = new URL(embedUrl);\\n+ return urlObj.pathname.slice(7); // Remove '/embed/'\\n+}\\n\"]}]\n</CHAT_HISTORY>\nExisting Chat Title: "
|