diff --git a/app/actions.tsx b/app/actions.tsx
index a1f5e915..50e985bf 100644
--- a/app/actions.tsx
+++ b/app/actions.tsx
@@ -7,7 +7,7 @@ import {
getMutableAIState
} from 'ai/rsc'
import { CoreMessage, ToolResultPart } from 'ai'
-import { nanoid } from 'nanoid'
+import { nanoid } from '@/lib/utils'
import type { FeatureCollection } from 'geojson'
import { Spinner } from '@/components/ui/spinner'
import { Section } from '@/components/section'
@@ -21,6 +21,7 @@ import { BotMessage } from '@/components/message'
import { SearchSection } from '@/components/search-section'
import SearchRelated from '@/components/search-related'
import { GeoJsonLayer } from '@/components/map/geojson-layer'
+import { ResolutionCarousel } from '@/components/resolution-carousel'
import { ResolutionImage } from '@/components/resolution-image'
import { CopilotDisplay } from '@/components/copilot-display'
import RetrieveSection from '@/components/retrieve-section'
@@ -50,18 +51,29 @@ async function submit(formData?: FormData, skip?: boolean) {
}
if (action === 'resolution_search') {
- const file = formData?.get('file') as File;
+ const file_mapbox = formData?.get('file_mapbox') as File;
+ const file_google = formData?.get('file_google') as File;
+ const file = (formData?.get('file') as File) || file_mapbox || file_google;
const timezone = (formData?.get('timezone') as string) || 'UTC';
+ const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
+ const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
+ const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;
if (!file) {
throw new Error('No file provided for resolution search.');
}
+ const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
+ const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;
+
+ const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
+ const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;
+
const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
- message =>
+ (message: any) =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
@@ -89,7 +101,7 @@ async function submit(formData?: FormData, skip?: boolean) {
async function processResolutionSearch() {
try {
- const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);
+ const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);
let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
@@ -113,7 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) {
messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });
- const sanitizedMessages: CoreMessage[] = messages.map(m => {
+ const sanitizedMessages: CoreMessage[] = messages.map((m: any) => {
if (Array.isArray(m.content)) {
return {
...m,
@@ -124,7 +136,7 @@ async function submit(formData?: FormData, skip?: boolean) {
})
const currentMessages = aiState.get().messages;
- const sanitizedHistory = currentMessages.map(m => {
+ const sanitizedHistory = currentMessages.map((m: any) => {
if (m.role === "user" && Array.isArray(m.content)) {
return {
...m,
@@ -159,7 +171,9 @@ async function submit(formData?: FormData, skip?: boolean) {
role: 'assistant',
content: JSON.stringify({
...analysisResult,
- image: dataUrl
+ image: dataUrl,
+ mapboxImage: mapboxDataUrl,
+ googleImage: googleDataUrl
}),
type: 'resolution_search_result'
},
@@ -190,7 +204,11 @@ async function submit(formData?: FormData, skip?: boolean) {
uiStream.update(
);
@@ -203,43 +221,20 @@ async function submit(formData?: FormData, skip?: boolean) {
};
}
- const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
- message =>
- message.role !== 'tool' &&
- message.type !== 'followup' &&
- message.type !== 'related' &&
- message.type !== 'end' &&
- message.type !== 'resolution_search_result'
- ).map(m => {
- if (Array.isArray(m.content)) {
- return {
- ...m,
- content: m.content.filter((part: any) =>
- part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
- )
- } as any
- }
- return m
- })
-
- const groupeId = nanoid()
- const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
- const maxMessages = useSpecificAPI ? 5 : 10
- messages.splice(0, Math.max(messages.length - maxMessages, 0))
-
+ const file = !skip ? (formData?.get('file') as File) : undefined
const userInput = skip
? `{"action": "skip"}`
: ((formData?.get('related_query') as string) ||
(formData?.get('input') as string))
- if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') {
+ if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) {
const definition = userInput.toLowerCase().trim() === 'what is a planet computer?'
? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)`
-
: `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`;
const content = JSON.stringify(Object.fromEntries(formData!));
const type = 'input';
+ const groupeId = nanoid();
aiState.update({
...aiState.get(),
@@ -299,10 +294,9 @@ async function submit(formData?: FormData, skip?: boolean) {
id: nanoid(),
isGenerating: isGenerating.value,
component: uiStream.value,
- isCollapsed: isCollapsed.value,
+ isCollapsed: isCollapsed.value
};
}
- const file = !skip ? (formData?.get('file') as File) : undefined
if (!userInput && !file) {
isGenerating.done(false)
@@ -314,6 +308,30 @@ async function submit(formData?: FormData, skip?: boolean) {
}
}
+ const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
+ (message: any) =>
+ message.role !== 'tool' &&
+ message.type !== 'followup' &&
+ message.type !== 'related' &&
+ message.type !== 'end' &&
+ message.type !== 'resolution_search_result'
+ ).map((m: any) => {
+ if (Array.isArray(m.content)) {
+ return {
+ ...m,
+ content: m.content.filter((part: any) =>
+ part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
+ )
+ } as any
+ }
+ return m
+ })
+
+ const groupeId = nanoid()
+ const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
+ const maxMessages = useSpecificAPI ? 5 : 10
+ messages.splice(0, Math.max(messages.length - maxMessages, 0))
+
const messageParts: {
type: 'text' | 'image'
text?: string
@@ -725,12 +743,18 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
+ const mapboxImage = analysisResult.mapboxImage as string;
+ const googleImage = analysisResult.googleImage as string;
return {
id,
component: (
<>
- {image && }
+
{geoJson && (
)}
diff --git a/app/layout.tsx b/app/layout.tsx
index 4369af43..b9ea46ea 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -112,7 +112,6 @@ export default function RootLayout({
{children}
-
diff --git a/app/page.tsx b/app/page.tsx
index 051e54bb..e098417e 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,5 @@
import { Chat } from '@/components/chat'
-import {nanoid } from 'nanoid'
+import { nanoid } from '@/lib/utils'
import { AI } from './actions'
export const maxDuration = 60
diff --git a/bun.lock b/bun.lock
index 1ca9af2f..f101e5d7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -50,7 +50,7 @@
"csv-parse": "^6.1.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.1",
- "drizzle-orm": "^0.45.1",
+ "drizzle-orm": "^0.29.0",
"embla-carousel-react": "^8.6.0",
"exa-js": "^1.6.13",
"framer-motion": "^12.23.24",
@@ -1313,7 +1313,7 @@
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
- "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
+ "drizzle-orm": ["drizzle-orm@0.29.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@libsql/client": "*", "@neondatabase/serverless": ">=0.1", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=13.2.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@libsql/client", "@neondatabase/serverless", "@opentelemetry/api", "@planetscale/database", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-jS3+uyzTz4P0Y2CICx8FmRQ1eplURPaIMWDn/yq6k4ShRFj9V7vlJk67lSf2kyYPzQ60GkkNGXcJcwrxZ6QCRw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
diff --git a/chat-panel.patch b/chat-panel.patch
new file mode 100644
index 00000000..7eadb3c1
--- /dev/null
+++ b/chat-panel.patch
@@ -0,0 +1,49 @@
+<<<<<<< SEARCH
+ // New chat button (appears when there are messages)
+ if (messages.length > 0 && !isMobile) {
+ return (
+
+
+
+ )
+ }
+=======
+ // New chat button (appears when there are messages)
+ if (messages.length > 0 && !isMobile) {
+ return (
+
+
+
+ )
+ }
+>>>>>>> REPLACE
diff --git a/components/calendar-notepad.tsx b/components/calendar-notepad.tsx
index 839120ae..2fdcb70d 100644
--- a/components/calendar-notepad.tsx
+++ b/components/calendar-notepad.tsx
@@ -1,13 +1,11 @@
"use client"
-
-import { Users } from "lucide-react";
-import { searchUsers } from "@/lib/actions/users";
import type React from "react"
import { useState, useEffect } from "react"
-import { ChevronLeft, ChevronRight, MapPin } from "lucide-react"
+import { ChevronLeft, ChevronRight, MapPin, Users } from "lucide-react"
import { cn } from "@/lib/utils"
import { getNotes, saveNote } from "@/lib/actions/calendar"
+import { searchUsers } from "@/lib/actions/users"
import { useMapData } from "./map/map-data-context"
import type { CalendarNote, NewCalendarNote } from "@/lib/types"
import { TimezoneClock } from "./timezone-clock"
@@ -76,21 +74,11 @@ export function CalendarNotepad({ chatId }: CalendarNotepadProps) {
setNotes([savedNote, ...notes])
setNoteContent("")
setTaggedLocation(null)
+ setShowSuggestions(false)
}
}
}
- const handleTagLocation = () => {
- if (mapData.targetPosition) {
- setTaggedLocation({
- type: 'Point',
- coordinates: mapData.targetPosition
- });
- setNoteContent(prev => `${prev} #location`);
- }
- };
-
-
const handleNoteContentChange = async (e: React.ChangeEvent) => {
const value = e.target.value;
setNoteContent(value);
@@ -126,6 +114,16 @@ export function CalendarNotepad({ chatId }: CalendarNotepadProps) {
});
};
+ const handleTagLocation = () => {
+ if (mapData.targetPosition) {
+ setTaggedLocation({
+ type: 'Point',
+ coordinates: mapData.targetPosition
+ });
+ setNoteContent(prev => `${prev} #location`);
+ }
+ };
+
const handleFlyTo = (location: any) => {
if (location && location.coordinates) {
setMapData(prev => ({ ...prev, targetPosition: location.coordinates }));
@@ -209,22 +207,24 @@ export function CalendarNotepad({ chatId }: CalendarNotepadProps) {
notes.map((note) => (
-
+
{new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{renderContent(note.content)}
- {note.locationTags && (
-
- )}
- {note.userTags && note.userTags.length > 0 && (
-
-
-
- )}
+
+ {note.locationTags && (
+
+ )}
+ {note.userTags && note.userTags.length > 0 && (
+
+
+
+ )}
+
))
diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx
index 7d877ccd..ca2fbc6f 100644
--- a/components/chat-panel.tsx
+++ b/components/chat-panel.tsx
@@ -6,9 +6,9 @@ import { useUIState, useActions, readStreamableValue } from 'ai/rsc'
import { cn } from '@/lib/utils'
import { UserMessage } from './user-message'
import { Button } from './ui/button'
-import { ArrowRight, Plus, Paperclip, X } from 'lucide-react'
+import { ArrowRight, Plus, Paperclip, X, Sprout } from 'lucide-react'
import Textarea from 'react-textarea-autosize'
-import { nanoid } from 'nanoid'
+import { nanoid } from '@/lib/utils'
import { useSettingsStore } from '@/lib/store/settings'
import { PartialRelated } from '@/lib/schema/related'
import { getSuggestions } from '@/lib/actions/suggest'
@@ -166,21 +166,19 @@ export const ChatPanel = forwardRef
(({ messages, i
return (
)
diff --git a/components/compare-slider.tsx b/components/compare-slider.tsx
new file mode 100644
index 00000000..34877148
--- /dev/null
+++ b/components/compare-slider.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import React, { useState, useRef, useEffect } from 'react'
+import { cn } from '@/lib/utils'
+
+interface CompareSliderProps {
+ leftImage: string
+ rightImage: string
+ className?: string
+}
+
+export function CompareSlider({ leftImage, rightImage, className }: CompareSliderProps) {
+ const [sliderPosition, setSliderPosition] = useState(50)
+ const containerRef = useRef(null)
+ const [containerWidth, setContainerWidth] = useState(0)
+
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ const observer = new ResizeObserver((entries) => {
+ for (let entry of entries) {
+ setContainerWidth(entry.contentRect.width)
+ }
+ })
+
+ observer.observe(containerRef.current)
+ return () => observer.disconnect()
+ }, [])
+
+ const handleMove = (event: React.MouseEvent | React.TouchEvent) => {
+ if (!containerRef.current) return
+
+ const containerRect = containerRef.current.getBoundingClientRect()
+ const x = 'touches' in event ? event.touches[0].clientX : (event as React.MouseEvent).clientX
+ const relativeX = x - containerRect.left
+ const position = Math.max(0, Math.min(100, (relativeX / containerRect.width) * 100))
+
+ setSliderPosition(position)
+ }
+
+ return (
+
+ {/* Right Image (Google Satellite) */}
+

+
+ {/* Left Image (Mapbox) */}
+
+
+

+
+
+
+ {/* Slider Handle */}
+
+
+ {/* Labels */}
+
+ MAPBOX
+
+
+ GOOGLE SATELLITE
+
+
+ )
+}
diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx
index 69fda09a..04896457 100644
--- a/components/header-search-button.tsx
+++ b/components/header-search-button.tsx
@@ -7,11 +7,12 @@ import { Search } from 'lucide-react'
import { useMap } from './map/map-context'
import { useActions, useUIState } from 'ai/rsc'
import { AI } from '@/app/actions'
-import { nanoid } from 'nanoid'
+import { nanoid } from '@/lib/utils'
import { UserMessage } from './user-message'
-import { toast } from 'react-toastify'
+import { toast } from 'sonner'
import { useSettingsStore } from '@/lib/store/settings'
import { useMapData } from './map/map-data-context'
+import { compressImage } from '@/lib/utils/image-utils'
// Define an interface for the actions to help TypeScript during build
interface HeaderActions {
@@ -48,7 +49,7 @@ export function HeaderSearchButton() {
setIsAnalyzing(true)
try {
- setMessages(currentMessages => [
+ setMessages((currentMessages: any[]) => [
...currentMessages,
{
id: nanoid(),
@@ -56,13 +57,42 @@ export function HeaderSearchButton() {
}
])
- let blob: Blob | null = null;
+ let mapboxBlob: Blob | null = null;
+ let googleBlob: Blob | null = null;
- if (mapProvider === 'mapbox') {
- const canvas = map!.getCanvas()
- blob = await new Promise(resolve => {
+ if (mapProvider === 'mapbox' && map) {
+ // Capture Mapbox
+ const canvas = map.getCanvas()
+ const rawMapboxBlob = await new Promise(resolve => {
canvas.toBlob(resolve, 'image/png')
})
+ if (rawMapboxBlob) {
+ mapboxBlob = await compressImage(rawMapboxBlob).catch(e => {
+ console.error('Failed to compress Mapbox image:', e);
+ return rawMapboxBlob;
+ });
+ }
+
+ // Also fetch Google Static Map for the same view
+ const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
+ if (apiKey) {
+ const center = map.getCenter();
+ const zoom = Math.round(map.getZoom());
+ const staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${center.lat},${center.lng}&zoom=${zoom}&size=640x480&scale=2&maptype=satellite&key=${apiKey}`;
+
+ try {
+ const response = await fetch(staticMapUrl);
+ if (response.ok) {
+ const rawGoogleBlob = await response.blob();
+ googleBlob = await compressImage(rawGoogleBlob).catch(e => {
+ console.error('Failed to compress Google image:', e);
+ return rawGoogleBlob;
+ });
+ }
+ } catch (e) {
+ console.error('Failed to fetch Google static map during Mapbox session:', e);
+ }
+ }
} else if (mapProvider === 'google') {
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
if (!apiKey || !mapData.cameraState) {
@@ -79,21 +109,36 @@ export function HeaderSearchButton() {
if (!response.ok) {
throw new Error('Failed to fetch static map image.');
}
- blob = await response.blob();
+ const rawGoogleBlob = await response.blob();
+ googleBlob = await compressImage(rawGoogleBlob).catch(e => {
+ console.error('Failed to compress Google image:', e);
+ return rawGoogleBlob;
+ });
}
- if (!blob) {
+ if (!mapboxBlob && !googleBlob) {
throw new Error('Failed to capture map image.')
}
const formData = new FormData()
- formData.append('file', blob, 'map_capture.png')
+ if (mapboxBlob) formData.append('file_mapbox', mapboxBlob, 'mapbox_capture.png')
+ if (googleBlob) formData.append('file_google', googleBlob, 'google_capture.png')
+
+ // Keep 'file' for backward compatibility if needed, or just use the first available
+ formData.append('file', (mapboxBlob || googleBlob)!, 'map_capture.png')
+
formData.append('action', 'resolution_search')
formData.append('timezone', mapData.currentTimezone || 'UTC')
formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))
+ const center = mapProvider === 'mapbox' && map ? map.getCenter() : mapData.cameraState?.center;
+ if (center) {
+ formData.append('latitude', center.lat.toString())
+ formData.append('longitude', center.lng.toString())
+ }
+
const responseMessage = await actions.submit(formData)
- setMessages(currentMessages => [...currentMessages, responseMessage as any])
+ setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
} catch (error) {
console.error('Failed to perform resolution search:', error)
toast.error('An error occurred while analyzing the map.')
diff --git a/components/history-list.tsx b/components/history-list.tsx
index 5713bd2e..5b67c538 100644
--- a/components/history-list.tsx
+++ b/components/history-list.tsx
@@ -2,31 +2,13 @@ import React, { cache } from 'react';
import HistoryItem from './history-item';
import { ClearHistory } from './clear-history';
import { getChats } from '@/lib/actions/chat';
-
-// Define the type for the chat data returned by getChats
-type ChatData = {
- userId: string;
- id: string;
- title: string;
- createdAt: Date;
- visibility: string | null;
-};
-
-// Define the Chat type expected by HistoryItem
-type Chat = {
- userId: string;
- id: string;
- title: string;
- createdAt: Date;
- visibility: string | null;
- path: string;
-};
+import type { Chat as DrizzleChat } from '@/lib/actions/chat-db';
type HistoryListProps = {
userId?: string;
};
-const loadChats = cache(async (userId?: string): Promise => {
+const loadChats = cache(async (userId?: string): Promise => {
return await getChats(userId);
});
@@ -52,12 +34,12 @@ export async function HistoryList({ userId }: HistoryListProps) {
No search history
) : (
- chats.map((chat: ChatData) => (
+ chats.map((chat: DrizzleChat) => (
))
diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx
index eecd7f54..55552b5d 100644
--- a/components/map/mapbox-map.tsx
+++ b/components/map/mapbox-map.tsx
@@ -5,8 +5,8 @@ import mapboxgl from 'mapbox-gl'
import MapboxDraw from '@mapbox/mapbox-gl-draw'
import * as turf from '@turf/turf'
import tzlookup from 'tz-lookup'
-import { toast } from 'react-toastify'
-import 'react-toastify/dist/ReactToastify.css'
+import { toast } from 'sonner'
+
import 'mapbox-gl/dist/mapbox-gl.css'
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'
import { useMapToggle, MapToggleEnum } from '../map-toggle-context'
diff --git a/components/resolution-carousel.tsx b/components/resolution-carousel.tsx
new file mode 100644
index 00000000..1b28cbf7
--- /dev/null
+++ b/components/resolution-carousel.tsx
@@ -0,0 +1,151 @@
+'use client'
+
+import React from 'react'
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from "@/components/ui/carousel"
+import { ResolutionImage } from './resolution-image'
+import { Button } from './ui/button'
+import { useActions, useUIState } from 'ai/rsc'
+import { AI } from '@/app/actions'
+import { nanoid } from '@/lib/utils'
+import { UserMessage } from './user-message'
+import { toast } from 'sonner'
+import { CompareSlider } from './compare-slider'
+import { compressImage } from '@/lib/utils/image-utils'
+
+interface ResolutionCarouselProps {
+ mapboxImage?: string | null
+ googleImage?: string | null
+ initialImage?: string | null
+}
+
+export function ResolutionCarousel({ mapboxImage, googleImage, initialImage }: ResolutionCarouselProps) {
+ const actions = useActions() as any
+ const [, setMessages] = useUIState()
+ const [isAnalyzing, setIsAnalyzing] = React.useState(false)
+
+ const handleQCXAnalysis = async () => {
+ if (!googleImage) return
+ setIsAnalyzing(true)
+
+ try {
+ const response = await fetch(googleImage)
+ const rawBlob = await response.blob()
+ const blob = await compressImage(rawBlob).catch(e => {
+ console.error('Failed to compress image for analysis:', e);
+ return rawBlob;
+ });
+
+ setMessages((currentMessages: any[]) => [
+ ...currentMessages,
+ {
+ id: nanoid(),
+ component:
+ }
+ ])
+
+ const formData = new FormData()
+ formData.append('file', blob, 'google_analysis.png')
+ formData.append('action', 'resolution_search')
+
+ const responseMessage = await actions.submit(formData)
+ setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any])
+ } catch (error) {
+ console.error('Failed to perform QCX-TERRA ANALYSIS:', error)
+ toast.error('An error occurred during analysis.')
+ } finally {
+ setIsAnalyzing(false)
+ }
+ }
+
+ const slides: Array<{ type: 'compare', left: string, right: string } | { type: 'image', src: string, showAnalysis: boolean, label: string }> = []
+
+ // Slide 1: Comparison (if both exist)
+ if (mapboxImage && googleImage) {
+ slides.push({
+ type: 'compare',
+ left: mapboxImage,
+ right: googleImage
+ })
+ }
+
+ // Individual slides
+ if (mapboxImage) slides.push({ type: 'image', src: mapboxImage, showAnalysis: false, label: 'MAPBOX' })
+ if (googleImage) slides.push({ type: 'image', src: googleImage, showAnalysis: true, label: 'GOOGLE SATELLITE' })
+
+ // Fallback
+ if (slides.length === 0 && initialImage) {
+ slides.push({ type: 'image', src: initialImage, showAnalysis: false, label: 'MAP CAPTURE' })
+ }
+
+ if (slides.length === 0) return null
+
+ if (slides.length === 1) {
+ const item = slides[0]
+ if (item.type === 'image') {
+ return (
+
+
+ {item.showAnalysis && (
+
+ )}
+
+ )
+ }
+ }
+
+ return (
+
+
+
+ {slides.map((slide, index) => (
+
+
+ {slide.type === 'compare' ? (
+
+ ) : (
+ <>
+
+ {slide.showAnalysis && (
+
+ )}
+
+ {slide.label}
+
+ >
+ )}
+
+
+ ))}
+
+ {slides.length > 1 && (
+
+
+
+
+ )}
+
+
+ )
+}
diff --git a/components/search-related.tsx b/components/search-related.tsx
index a03901c5..3dbb49c8 100644
--- a/components/search-related.tsx
+++ b/components/search-related.tsx
@@ -12,7 +12,7 @@ import {
import { AI } from '@/app/actions'
import { UserMessage } from './user-message'
import { PartialRelated } from '@/lib/schema/related'
-import { nanoid } from 'nanoid'
+import { nanoid } from '@/lib/utils'
export interface SearchRelatedProps {
relatedQueries: StreamableValue
diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx
index 0d201916..7a526e18 100644
--- a/components/settings/components/settings.tsx
+++ b/components/settings/components/settings.tsx
@@ -1,19 +1,18 @@
-"use client"
-
-import { useState, useEffect } from "react"
-import { useRouter } from "next/navigation"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { z } from "zod"
-import * as Tabs from "@radix-ui/react-tabs";
-import { Button } from "@/components/ui/button"
-import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
-import { FormProvider, UseFormReturn } from "react-hook-form"; import React from "react";
-import { Loader2, Save, RotateCcw } from "lucide-react"
-import { motion, AnimatePresence } from "framer-motion"
-// Or, if the file does not exist, create it as shown below.
-import { SystemPromptForm } from "./system-prompt-form"
-import { ModelSelectionForm } from "./model-selection-form"
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import * as Tabs from '@radix-ui/react-tabs'
+import { useRouter } from 'next/navigation'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useForm } from 'react-hook-form'
+import * as z from 'zod'
+import { Loader2, Save, RotateCcw } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
+import { SystemPromptForm } from './system-prompt-form'
+import { ModelSelectionForm } from './model-selection-form'
import { UserManagementForm } from './user-management-form';
import { Form } from "@/components/ui/form"
import { useSettingsStore, MapProvider } from "@/lib/store/settings";
@@ -22,8 +21,10 @@ import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/hooks/use-toast"
import { getSystemPrompt, saveSystemPrompt } from "../../../lib/actions/chat"
import { getSelectedModel, saveSelectedModel } from "../../../lib/actions/users"
+import { useCurrentUser } from "@/lib/auth/use-current-user"
+import { SettingsSkeleton } from './settings-skeleton'
-// Define the form schema
+// Define the form schema with enum validation for roles
const settingsFormSchema = z.object({
systemPrompt: z
.string()
@@ -43,7 +44,7 @@ const settingsFormSchema = z.object({
role: z.enum(["admin", "editor", "viewer"]),
}),
),
- newUserEmail: z.string().email().optional(),
+ newUserEmail: z.string().email().optional().or(z.literal('')),
newUserRole: z.enum(["admin", "editor", "viewer"]).optional(),
})
@@ -54,10 +55,7 @@ const defaultValues: Partial = {
systemPrompt:
"You are a planetary copilot, an AI assistant designed to help users with information about planets, space exploration, and astronomy. Provide accurate, educational, and engaging responses about our solar system and beyond.",
selectedModel: "Grok 4.2",
- users: [
- { id: "1", email: "admin@example.com", role: "admin" },
- { id: "2", email: "user@example.com", role: "editor" },
- ],
+ users: [],
}
interface SettingsProps {
@@ -67,16 +65,16 @@ interface SettingsProps {
export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
const { toast } = useToast()
const router = useRouter()
- const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
const [currentTab, setCurrentTab] = useState(initialTab);
const { mapProvider, setMapProvider } = useSettingsStore();
+ const { user, loading: authLoading } = useCurrentUser();
useEffect(() => {
setCurrentTab(initialTab);
}, [initialTab]);
- // TODO: Replace 'anonymous' with actual user ID from session/auth context
- const userId = 'anonymous';
+ const userId = user?.id;
const form = useForm({
resolver: zodResolver(settingsFormSchema),
@@ -85,6 +83,8 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
useEffect(() => {
async function fetchData() {
+ if (!userId || authLoading) return;
+
const [existingPrompt, selectedModel] = await Promise.all([
getSystemPrompt(userId),
getSelectedModel(),
@@ -98,10 +98,23 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
}
}
fetchData();
- }, [form, userId]);
+ }, [form, userId, authLoading]);
+
+ if (authLoading) {
+ return ;
+ }
async function onSubmit(data: SettingsFormValues) {
- setIsLoading(true)
+ if (!userId) {
+ toast({
+ title: "Error",
+ description: "You must be logged in to save settings.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setIsSaving(true)
try {
// Save the system prompt and selected model
@@ -124,9 +137,6 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
title: "Settings updated",
description: "Your settings have been saved successfully.",
})
-
- // Refresh the page to reflect changes
- // router.refresh(); // Consider if refresh is needed or if optimistic update is enough
} catch (error: any) {
// Error notification
toast({
@@ -135,7 +145,7 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
variant: "destructive",
})
} finally {
- setIsLoading(false)
+ setIsSaving(false)
}
}
@@ -223,12 +233,12 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
-