Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions dashboard/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,74 @@ export const buildCutoutUrl = (objectKey: string): string => {
return url;
};

// Cache for blob URLs to avoid re-fetching images
const blobCache = new Map<string, string>();

// Simple async wrapper (keeps existing calling pattern with react-query)
export const getCutoutObject = async (objectKey: string): Promise<string | null> => {
const url = buildCutoutUrl(objectKey);

// In dev with proxy we just return the proxied path; browser will request via Vite server
if ((import.meta as any).env?.DEV) {
return url;
}
// In production (no proxy) just return direct URL (CORS must be handled server-side)
return url;

// Check if we already have a blob URL for this image
if (blobCache.has(url)) {
const cachedUrl = blobCache.get(url)!;
if ((import.meta as { env?: Record<string, string> }).env?.VITE_DEBUG_CUTOUTS) {
console.debug('[cutout-blob-cache-hit]', { objectKey, url, cachedUrl });
}
return cachedUrl;
}

try {
if ((import.meta as { env?: Record<string, string> }).env?.VITE_DEBUG_CUTOUTS) {
console.debug('[cutout-fetch-start]', { objectKey, url });
}

// In production, fetch the image with proper headers to bypass zrok interstitial
const response = await fetch(url, {
headers: {
'skip_zrok_interstitial': 'true',
'Accept': 'image/*'
},
cache: 'force-cache'
});

if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}

// Create blob URL from the response
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);

// Cache the blob URL
blobCache.set(url, blobUrl);

if ((import.meta as { env?: Record<string, string> }).env?.VITE_DEBUG_CUTOUTS) {
console.debug('[cutout-blob-created]', { objectKey, url, blobUrl, blobSize: blob.size });
}

return blobUrl;
} catch (error) {
console.error('Failed to fetch cutout image:', { objectKey, url, error });
// Fallback to direct URL if fetch fails
return url;
}
};

// Utility function to clean up blob URLs (can be called from components)
export const revokeBlobUrl = (url: string) => {
if (url && url.startsWith('blob:')) {
URL.revokeObjectURL(url);
// Remove from cache
for (const [key, value] of blobCache.entries()) {
if (value === url) {
blobCache.delete(key);
break;
}
}
}
};
22 changes: 18 additions & 4 deletions dashboard/src/components/CutoutGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import React, { memo, useEffect } from 'react';
import { Box, Grid, Typography, Paper, Skeleton } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { getCutoutObject } from '../api';
import { getCutoutObject, revokeBlobUrl } from '../api';
import type { CutoutRecord } from '../types';

interface Props {
Expand Down Expand Up @@ -34,6 +34,15 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => {
retry: 1, // Only retry once for ngrok issues
});

// Clean up blob URLs when component unmounts or data changes
useEffect(() => {
return () => {
if (data) {
revokeBlobUrl(data);
}
};
}, [data]);

return (
<Paper sx={{ p: 1.5, textAlign: 'center', background: 'linear-gradient(145deg, rgba(40,65,75,0.6), rgba(25,40,50,0.4))', border: '1px solid rgba(90,170,200,0.3)' }}>
<Typography variant="caption" sx={{ fontWeight: 700, letterSpacing:0.5 }}>{record.band}</Typography>
Expand All @@ -51,8 +60,13 @@ const CutoutCard: React.FC<{ record: CutoutRecord }> = memo(({ record }) => {
if(img.dataset.fallbackTried) return;
img.dataset.fallbackTried = '1';

// For ngrok URLs, try adding the bypass parameters as query string
if (img.src.includes('ngrok')) {
// For blob URLs, we can't retry with different parameters
if (img.src.startsWith('blob:')) {
return;
}

// For ngrok/zrok URLs, try adding the bypass parameters as query string
if (img.src.includes('ngrok') || img.src.includes('zrok')) {
const url = new URL(img.src);
url.searchParams.set('skip_zrok_interstitial', 'true');
img.src = url.toString();
Expand Down
20 changes: 18 additions & 2 deletions dashboard/src/components/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import type { CutoutRecord } from '../types';
import { getCutoutObject } from '../api';
import { getCutoutObject, revokeBlobUrl } from '../api';

interface GalleryProps {
cutouts: CutoutRecord[];
Expand Down Expand Up @@ -34,6 +34,14 @@ export const Gallery: React.FC<GalleryProps> = ({ cutouts }) => {
setImages([]);
setLoadedCount(0);
if(filtered.length === 0) return;

// Clean up previous blob URLs
images.forEach(img => {
if (img.url) {
revokeBlobUrl(img.url);
}
});

(async () => {
const entries: ImgEntry[] = [];
for(const c of filtered){
Expand All @@ -50,7 +58,15 @@ export const Gallery: React.FC<GalleryProps> = ({ cutouts }) => {
setImages(e=> [...e, ...entries.filter(ne=> !e.find(prev => prev.key === ne.key))]);
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
// Clean up blob URLs when component unmounts
images.forEach(img => {
if (img.url) {
revokeBlobUrl(img.url);
}
});
};
}, [filtered]);

const progress = filtered.length === 0 ? 0 : Math.min(100, Math.round( (loadedCount / filtered.length) * 100));
Expand Down
Loading