Skip to content

Report Generation #569

@ngoiyaeric

Description

@ngoiyaeric

PDF Report Generation — Implementation Plan for QCX

This feature does not exist yet in the codebase. There are zero existing PDF, report, export, or download utilities anywhere in the project. However, all the raw data you need is already being captured and stored. Here is a precise breakdown of what exists and exactly what needs to be built.


What Data Is Already Available

1. Questions & Responses (Chat Messages)

The full conversation history lives in AIState.messages, an array of AIMessage objects. Each message has a type field that distinguishes user questions from AI answers:

  • User questions: type === 'input' or type === 'input_related'
  • AI answers: type === 'response'
  • Map analysis results: type === 'resolution_search_result' 1

The AIState shape (holding all messages for the session) and the getUIStateFromAIState function (which reconstructs the full conversation from stored state) are both in app/actions.tsx: 2 3

Messages are persisted to a Postgres database via the messages and chats tables and can be retrieved by chat ID using getMessagesByChatId: 4 5


2. Map Screenshots (Images)

When a resolution search is run, the Mapbox screenshot, Google Satellite screenshot, and original image are all captured as base64 data URLs and stored directly in the resolution_search_result message content: 6

These are later parsed and rendered in ResolutionCarousel and ResolutionImage components: 7

Additionally, the live Mapbox map instance is initialized with preserveDrawingBuffer: true, which means at any moment you can call map.getCanvas().toDataURL() to capture the current map view as a PNG — even with drawn features visible: 8

The live map instance is accessible globally via the MapContext / useMap() hook: 9


3. Drawn Features & Measurements

All polygons and line strings drawn by the user, along with their computed area/distance measurements, are stored in MapData.drawnFeatures via the MapDataContext. Each entry contains the feature type, its geometry (GeoJSON), and its human-readable measurement string: 10

These are computed and stored whenever a shape is drawn or updated via updateMeasurementLabels, which builds the currentDrawnFeatures array from MapboxDraw's state: 11

The drawn features are also passed to the AI on every form submission via a hidden drawnFeatures field: 12


4. GeoJSON Layers

When the AI performs a geospatial analysis, it may return a geoJson field inside the resolution_search_result message, which is rendered as a map overlay: 13


What Needs to Be Built

Step 1 — Add a PDF Library

No PDF library exists in the project dependencies: 14

You need to add one. The two most compatible options with this Next.js 15 / React 19 stack are:

  • @react-pdf/renderer — compose the PDF as React components; works entirely client-side
  • jsPDF + html2canvas — screenshot DOM sections and embed them; simpler for rich content

Step 2 — Create a PDF Report Generator Utility

Create a new file (e.g., lib/utils/report-generator.ts) that accepts the following inputs:

  • messages: AIMessage[] — from getAIState() or useAIState() (already typed in lib/types/index.ts)
  • drawnFeatures: MapData['drawnFeatures'] — from useMapData() context
  • mapSnapshot: string — from map.getCanvas().toDataURL() using the useMap() hook
  • chatTitle: string — from the chats table title

The report structure should be:

1. Cover page (chat title, date, map snapshot)
2. For each Q&A turn:
   a. User question (type: 'input' or 'input_related')
   b. AI response (type: 'response') — render markdown
   c. Map images if present (type: 'resolution_search_result' → image, mapboxImage, googleImage)
   d. GeoJSON summary if present
3. Drawings appendix (each drawn feature: type, measurement, coordinates)

Step 3 — Add a "Download Report" Button

The best place to add this is in the header, next to the existing action buttons (Calendar, Map Toggle, etc.): 15

The button component should:

  1. Call useAIState() to read aiState.messages
  2. Call useMapData() to read mapData.drawnFeatures
  3. Call useMap() to get the live map instance and call .getCanvas().toDataURL()
  4. Call the report generator, then trigger a browser download via URL.createObjectURL(blob)

The sharing button in chat-share.tsx is fully disabled and returns null, so there is no existing share/export UI to reuse: 16


Step 4 — Wire Up Message Filtering

Use the same filtering logic already applied in submit() to isolate only user-facing content (exclude 'tool', 'followup', 'related', 'end' types): 17

For 'resolution_search_result' messages, parse the JSON content to extract image, mapboxImage, googleImage, and geoJson: 7


Architecture Diagram

flowchart TD
    A["User clicks 'Download Report' button"] --> B["useAIState() → AIMessage[]"]
    A --> C["useMapData() → drawnFeatures[]"]
    A --> D["useMap() → map.getCanvas().toDataURL()"]
    B --> E["Filter messages:\n input, response,\n resolution_search_result"]
    E --> F["Parse Q&A pairs"]
    E --> G["Parse map images\n (mapboxImage, googleImage, image)"]
    E --> H["Parse GeoJSON layers"]
    C --> I["Drawings appendix:\n type + measurement + geometry"]
    D --> J["Live map snapshot PNG"]
    F & G & H & I & J --> K["PDF Composer\n (@react-pdf/renderer or jsPDF)"]
    K --> L["Blob download triggered\n in browser"]
Loading

Notes

  • preserveDrawingBuffer: true on the Mapbox map is the critical enabler for live map screenshots. This is already set in the codebase and means no workaround is needed.
  • The compressImage utility in lib/utils/image-utils.ts can be reused to reduce the size of map images before embedding them in the PDF. 18
  • Chat history is persisted to Postgres, so reports can also be generated server-side (e.g., in a new API route under app/api/) from a past chat ID using getMessagesByChatId(chatId), not just from the live session. 4
  • The calendarNotes table also links notes to chats and could optionally be included in the report as a "scheduled observations" section. 19
  • The visualizations table stores GeoJSON map layers linked to chats, which could serve as an additional data source for reports. 20

Citations

File: lib/types/index.ts (L59-77)

export type AIMessage = {
  role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
  content: CoreMessage['content']
  id: string
  name?: string
  createdAt?: Date // Added optional createdAt timestamp
  type?:
    | 'response'
    | 'related'
    | 'skip'
    | 'inquiry'
    | 'input'
    | 'input_related'
    | 'tool'
    | 'followup'
    | 'end'
    | 'drawing_context' // Added custom type for drawing context messages
    | 'resolution_search_result'
}

File: app/actions.tsx (L116-124)

        if (analysisResult.geoJson) {
          uiStream.append(
            <GeoJsonLayer
              id={groupeId}
              data={analysisResult.geoJson as FeatureCollection}
            />
          );
        }

File: app/actions.tsx (L159-183)

        aiState.done({
          ...aiState.get(),
          messages: [
            ...aiState.get().messages,
            {
              id: groupeId,
              role: 'assistant',
              content: analysisResult.summary || 'Analysis complete.',
              type: 'response'
            },
            {
              id: groupeId,
              role: 'assistant',
              content: JSON.stringify({
                ...analysisResult,
                image: dataUrl,
                mapboxImage: mapboxDataUrl,
                googleImage: googleDataUrl
              }),
              type: 'resolution_search_result'
            },
            {
              id: groupeId,
              role: 'assistant',
              content: JSON.stringify(relatedQueries),

File: app/actions.tsx (L311-328)

  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
  })

File: app/actions.tsx (L561-572)

export type AIState = {
  messages: AIMessage[]
  chatId: string
  isSharePage?: boolean
}

export type UIState = {
  id: string
  component: React.ReactNode
  isGenerating?: StreamableValue<boolean>
  isCollapsed?: StreamableValue<boolean>
}[]

File: app/actions.tsx (L662-848)

export const getUIStateFromAIState = (aiState: AIState): UIState => {
  const chatId = aiState.chatId
  const isSharePage = aiState.isSharePage
  return aiState.messages
    .map((message, index) => {
      const { role, content, id, type, name } = message

      if (
        !type ||
        type === 'end' ||
        (isSharePage && type === 'related') ||
        (isSharePage && type === 'followup')
      )
        return null

      switch (role) {
        case 'user':
          switch (type) {
            case 'input':
            case 'input_related':
              let messageContent: string | any[]
              try {
                const json = JSON.parse(content as string)
                messageContent =
                  type === 'input' ? json.input : json.related_query
              } catch (e) {
                messageContent = content
              }
              return {
                id,
                component: (
                  <UserMessage
                    content={messageContent}
                    chatId={chatId}
                    showShare={index === 0 && !isSharePage}
                  />
                )
              }
            case 'inquiry':
              return {
                id,
                component: <CopilotDisplay content={content as string} />
              }
          }
          break
        case 'assistant':
          const answer = createStreamableValue(content as string)
          answer.done(content as string)
          switch (type) {
            case 'response':
              return {
                id,
                component: (
                  <Section title="response">
                    <BotMessage content={answer.value} />
                  </Section>
                )
              }
            case 'related':
              const relatedQueries = createStreamableValue<RelatedQueries>({
                items: []
              })
              relatedQueries.done(JSON.parse(content as string))
              return {
                id,
                component: (
                  <Section title="Related" separator={true}>
                    <SearchRelated relatedQueries={relatedQueries.value} />
                  </Section>
                )
              }
            case 'followup':
              return {
                id,
                component: (
                  <Section title="Follow-up" className="pb-8">
                    <FollowupPanel />
                  </Section>
                )
              }
            case 'resolution_search_result': {
              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: (
                  <>
                    <ResolutionCarousel
                      mapboxImage={mapboxImage}
                      googleImage={googleImage}
                      initialImage={image}
                    />
                    {geoJson && (
                      <GeoJsonLayer id={id} data={geoJson} />
                    )}
                  </>
                )
              }
            }
          }
          break
        case 'tool':
          try {
            const toolOutput = JSON.parse(content as string)
            const isCollapsed = createStreamableValue(true)
            isCollapsed.done(true)

            if (
              toolOutput.type === 'MAP_QUERY_TRIGGER' &&
              name === 'geospatialQueryTool'
            ) {
              const mapUrl = toolOutput.mcp_response?.mapUrl;
              const placeName = toolOutput.mcp_response?.location?.place_name;

              return {
                id,
                component: (
                  <>
                    {mapUrl && (
                      <ResolutionImage
                        src={mapUrl}
                        className="mb-0"
                        alt={placeName ? `Map of ${placeName}` : 'Map Preview'}
                      />
                    )}
                    <MapQueryHandler toolOutput={toolOutput} />
                  </>
                ),
                isCollapsed: false
              }
            }

            const searchResults = createStreamableValue(
              JSON.stringify(toolOutput)
            )
            searchResults.done(JSON.stringify(toolOutput))
            switch (name) {
              case 'search':
                return {
                  id,
                  component: <SearchSection result={searchResults.value} />,
                  isCollapsed: isCollapsed.value
                }
              case 'retrieve':
                return {
                  id,
                  component: <RetrieveSection data={toolOutput} />,
                  isCollapsed: isCollapsed.value
                }
              case 'videoSearch':
                return {
                  id,
                  component: (
                    <VideoSearchSection result={searchResults.value} />
                  ),
                  isCollapsed: isCollapsed.value
                }
              default:
                console.warn(
                  `Unhandled tool result in getUIStateFromAIState: ${name}`
                )
                return { id, component: null }
            }
          } catch (error) {
            console.error(
              'Error parsing tool content in getUIStateFromAIState:',
              error
            )
            return {
              id,
              component: null
            }
          }
          break
        default:
          return {
            id,
            component: null
          }
      }
    })
    .filter(message => message !== null) as UIState
}

File: lib/actions/chat-db.ts (L198-214)

export async function getMessagesByChatId(chatId: string): Promise<Message[]> {
  if (!chatId) {
    console.warn('getMessagesByChatId called without chatId');
    return [];
  }
  try {
    const result = await db
      .select()
      .from(messages)
      .where(eq(messages.chatId, chatId))
      .orderBy(asc(messages.createdAt)); // Order messages chronologically
    return result;
  } catch (error) {
    console.error(`Error fetching messages for chat ${chatId}:`, error);
    return [];
  }
}

File: lib/db/schema.ts (L52-61)

export const messages = pgTable('messages', {
  id: uuid('id').primaryKey().defaultRandom(),
  chatId: uuid('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  role: text('role').notNull(),
  content: text('content').notNull(),
  embedding: vector('embedding'),
  locationId: uuid('location_id').references(() => locations.id, { onDelete: 'set null' }),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

File: lib/db/schema.ts (L82-90)

export const visualizations = pgTable('visualizations', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  chatId: uuid('chat_id').references(() => chats.id, { onDelete: 'cascade' }),
  type: text('type').notNull().default('map_layer'),
  data: jsonb('data').notNull(),
  geometry: geometry('geometry'),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

File: lib/db/schema.ts (L92-103)

export const calendarNotes = pgTable('calendar_notes', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  chatId: uuid('chat_id').references(() => chats.id, { onDelete: 'cascade' }),
  date: timestamp('date', { withTimezone: true }).notNull(),
  content: text('content').notNull(),
  locationTags: jsonb('location_tags'),
  userTags: text('user_tags').array(),
  mapFeatureId: text('map_feature_id'),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});

File: components/map/mapbox-map.tsx (L67-168)

  const updateMeasurementLabels = useCallback(() => {
    if (!map.current || !drawRef.current) return

    // Remove existing labels
    Object.values(polygonLabelsRef.current).forEach(marker => marker.remove())
    Object.values(lineLabelsRef.current).forEach(marker => marker.remove())
    polygonLabelsRef.current = {}
    lineLabelsRef.current = {}

    const features = drawRef.current.getAll().features
    const currentDrawnFeatures: Array<{ id: string; type: 'Polygon' | 'LineString'; measurement: string; geometry: any }> = []

    features.forEach(feature => {
      const id = feature.id as string
      let featureType: 'Polygon' | 'LineString' | null = null;
      let measurement = '';

      if (feature.geometry.type === 'Polygon') {
        featureType = 'Polygon';
        // Calculate area for polygons
        const area = turf.area(feature)
        const formattedArea = formatMeasurement(area, true)
        measurement = formattedArea;
        
        // Get centroid for label placement
        const centroid = turf.centroid(feature)
        const coordinates = centroid.geometry.coordinates
        
        // Create a label
        const el = document.createElement('div')
        el.className = 'area-label'
        el.style.background = 'rgba(255, 255, 255, 0.8)'
        el.style.padding = '4px 8px'
        el.style.borderRadius = '4px'
        el.style.fontSize = '12px'
        el.style.fontWeight = 'bold'
        el.style.color = '#333333' // Added darker color
        el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'
        el.style.pointerEvents = 'none'
        el.textContent = formattedArea
        
        // Add marker for the label





        if (map.current) {
          const marker = new mapboxgl.Marker({ element: el })
            .setLngLat(coordinates as [number, number])
            .addTo(map.current)
            
          polygonLabelsRef.current[id] = marker
        }
      } 
      else if (feature.geometry.type === 'LineString') {
        featureType = 'LineString';
        // Calculate length for lines
        const length = turf.length(feature, { units: 'kilometers' }) * 1000 // Convert to meters
        const formattedLength = formatMeasurement(length, false)
        measurement = formattedLength;
        
        // Get midpoint for label placement
        const line = feature.geometry.coordinates
        const midIndex = Math.floor(line.length / 2) - 1
        const midpoint = midIndex >= 0 ? line[midIndex] : line[0]
        
        // Create a label
        const el = document.createElement('div')
        el.className = 'distance-label'
        el.style.background = 'rgba(255, 255, 255, 0.8)'
        el.style.padding = '4px 8px'
        el.style.borderRadius = '4px'
        el.style.fontSize = '12px'
        el.style.fontWeight = 'bold'
        el.style.color = '#333333' // Added darker color
        el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'
        el.style.pointerEvents = 'none'
        el.textContent = formattedLength
        
        // Add marker for the label
        if (map.current) {
          const marker = new mapboxgl.Marker({ element: el })
            .setLngLat(midpoint as [number, number])
            .addTo(map.current)
            
          lineLabelsRef.current[id] = marker
        }
      }

      if (featureType && id && measurement && feature.geometry) {
        currentDrawnFeatures.push({
          id,
          type: featureType,
          measurement,
          geometry: feature.geometry,
        });
      }
    })

    setMapData(prevData => ({ ...prevData, drawnFeatures: currentDrawnFeatures }))
  }, [formatMeasurement, setMapData])

File: components/map/mapbox-map.tsx (L396-406)

      map.current = new mapboxgl.Map({
        container: mapContainer.current,
        style: 'mapbox://styles/mapbox/satellite-streets-v12',
        center: initialCenter,
        zoom: initialZoom,
        pitch: initialPitch,
        bearing: initialBearing,
        maxZoom: 22,
        attributionControl: true,
        preserveDrawingBuffer: true
      })

File: components/map/map-context.tsx (L1-30)

'use client'

import { createContext, useContext, useState, ReactNode } from 'react'
import type { Map as MapboxMap } from 'mapbox-gl'

// A more direct context to hold the map instance itself.
type MapContextType = {
  map: MapboxMap | null;
  setMap: (map: MapboxMap | null) => void;
};

const MapContext = createContext<MapContextType | undefined>(undefined);

export const MapProvider = ({ children }: { children: ReactNode }) => {
  const [map, setMap] = useState<MapboxMap | null>(null);

  return (
    <MapContext.Provider value={{ map, setMap }}>
      {children}
    </MapContext.Provider>
  );
};

export const useMap = (): MapContextType => {
  const context = useContext(MapContext);
  if (!context) {
    throw new Error('useMap must be used within a MapProvider');
  }
  return context;
};

File: components/map/map-data-context.tsx (L15-32)

export interface MapData {
  targetPosition?: { lat: number; lng: number } | null; // For flying to a location
  cameraState?: CameraState; // For saving camera state
  currentTimezone?: string; // Current timezone identifier
  // TODO: Add other relevant map data types later (e.g., routeGeoJSON, poiList)
  mapFeature?: any | null; // Generic feature from MCP hook's processLocationQuery
  drawnFeatures?: Array<{ // Added to store drawn features and their measurements
    id: string;
    type: 'Polygon' | 'LineString';
    measurement: string;
    geometry: any;
  }>;
  markers?: Array<{
    latitude: number;
    longitude: number;
    title?: string;
  }>;
}

File: components/chat-panel.tsx (L118-120)

    // Include drawn features in the form data
    formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))

File: package.json (L18-101)

  "dependencies": {
    "@ai-sdk/amazon-bedrock": "^1.1.6",
    "@ai-sdk/anthropic": "^1.2.12",
    "@ai-sdk/google": "^1.2.22",
    "@ai-sdk/openai": "^1.3.24",
    "@ai-sdk/xai": "^1.2.18",
    "@composio/core": "^0.3.3",
    "@google-cloud/storage": "^7.18.0",
    "@google/generative-ai": "^0.24.1",
    "@heroicons/react": "^2.2.0",
    "@hookform/resolvers": "3.3.4",
    "@mapbox/mapbox-gl-draw": "^1.5.0",
    "@modelcontextprotocol/sdk": "^1.13.0",
    "@radix-ui/react-alert-dialog": "^1.1.10",
    "@radix-ui/react-avatar": "^1.1.6",
    "@radix-ui/react-checkbox": "^1.2.2",
    "@radix-ui/react-collapsible": "^1.1.7",
    "@radix-ui/react-dialog": "^1.1.10",
    "@radix-ui/react-dropdown-menu": "^2.1.11",
    "@radix-ui/react-label": "^2.1.4",
    "@radix-ui/react-radio-group": "^1.3.4",
    "@radix-ui/react-separator": "^1.1.4",
    "@radix-ui/react-slider": "^1.3.2",
    "@radix-ui/react-slot": "^1.2.0",
    "@radix-ui/react-switch": "^1.2.2",
    "@radix-ui/react-tabs": "^1.1.9",
    "@radix-ui/react-toast": "^1.2.11",
    "@radix-ui/react-tooltip": "^1.2.3",
    "@supabase/ssr": "^0.3.0",
    "@supabase/supabase-js": "^2.0.0",
    "@tailwindcss/typography": "^0.5.16",
    "@tavily/core": "^0.6.4",
    "@turf/turf": "^7.2.0",
    "@types/mapbox__mapbox-gl-draw": "^1.4.8",
    "@types/pg": "^8.15.4",
    "@upstash/redis": "^1.35.0",
    "@vercel/analytics": "^1.5.0",
    "@vercel/speed-insights": "^1.2.0",
    "@vis.gl/react-google-maps": "^1.7.1",
    "ai": "^4.3.19",
    "build": "^0.1.4",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "cookie": "^0.6.0",
    "csv-parse": "^6.1.0",
    "dotenv": "^16.5.0",
    "drizzle-kit": "^0.31.1",
    "drizzle-orm": "^0.29.0",
    "embla-carousel-react": "^8.6.0",
    "exa-js": "^1.6.13",
    "framer-motion": "^12.23.24",
    "geotiff": "^2.1.4-beta.1",
    "glassmorphic": "^0.0.3",
    "katex": "^0.16.22",
    "lodash": "^4.17.21",
    "lottie-react": "^2.4.1",
    "lucide-react": "^0.507.0",
    "mapbox-gl": "^3.11.0",
    "next": "15.3.8",
    "next-themes": "^0.3.0",
    "open-codex": "^0.1.30",
    "pg": "^8.16.2",
    "proj4": "^2.20.2",
    "radix-ui": "^1.3.4",
    "react": "19.1.2",
    "react-dom": "19.1.2",
    "react-hook-form": "^7.56.2",
    "react-icons": "^5.5.0",
    "react-markdown": "^9.1.0",
    "react-textarea-autosize": "^8.5.9",
    "react-toastify": "^10.0.6",
    "rehype-external-links": "^3.0.0",
    "rehype-katex": "^7.0.1",
    "remark-gfm": "^4.0.1",
    "remark-math": "^6.0.0",
    "sonner": "^1.7.4",
    "tailwind-merge": "^2.6.0",
    "tailwindcss-animate": "^1.0.7",
    "tz-lookup": "^6.1.25",
    "use-mcp": "^0.0.9",
    "uuid": "^9.0.0",
    "zod": "^3.25.0",
    "zustand": "^5.0.9"
  },

File: components/header.tsx (L69-87)

      <div className="flex-1 hidden md:flex justify-center gap-10 items-center z-10">
        <ProfileToggle/>
        
        <MapToggle />
        
        <Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="calendar-toggle">
          <CalendarDays className="h-[1.2rem] w-[1.2rem]" />
        </Button>
        
        <div id="header-search-portal" className="contents" />
        
        <Button variant="ghost" size="icon" onClick={handleUsageToggle}>
          <TentTree className="h-[1.2rem] w-[1.2rem]" />
        </Button>
        
        <ModeToggle />
        
        <HistoryContainer location="header" />
      </div>

File: components/chat-share.tsx (L63-66)

  // TODO: Re-evaluate/reimplement sharing with Supabase. For now, disable the UI.
  if (true) { // Conditionally disable the share button/dialog
    return null; // Or return a disabled button: <Button className={className} variant="ghost" size="icon" disabled><Share size={14} /></Button>
  }

File: lib/utils/image-utils.ts (L4-51)

export async function compressImage(blob: Blob, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      let width = img.width;
      let height = img.height;

      if (width > height) {
        if (width > maxWidth) {
          height *= maxWidth / width;
          width = maxWidth;
        }
      } else {
        if (height > maxHeight) {
          width *= maxHeight / height;
          height = maxHeight;
        }
      }

      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;

      const ctx = canvas.getContext('2d');
      if (!ctx) {
        reject(new Error('Failed to get canvas context'));
        return;
      }

      ctx.drawImage(img, 0, 0, width, height);

      canvas.toBlob(
        (compressedBlob) => {
          if (compressedBlob) {
            resolve(compressedBlob);
          } else {
            reject(new Error('Failed to compress image'));
          }
        },
        'image/jpeg',
        quality
      );
    };

    img.onerror = () => reject(new Error('Failed to load image for compression'));
    img.src = URL.createObjectURL(blob);
  });
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions