Skip to content

Commit 47d3094

Browse files
FIX : Shared memory cache for view and redact
1 parent 2ad0aa4 commit 47d3094

File tree

3 files changed

+120
-54
lines changed

3 files changed

+120
-54
lines changed

frontend/src/components/library/CaseLibrary.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,9 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
292292

293293
const handleRedactFile = useCallback(
294294
async (file: LibraryFile) => {
295-
// Check cache — if hit, open with URL immediately
296-
const cachedUrl = getCachedUrl(caseId, file.id);
295+
// Check cache or preloaded URLs — if hit, open with URL immediately
296+
const cachedUrl =
297+
getCachedUrl(caseId, file.id) || preloadedUrls.get(file.id) || null;
297298
if (cachedUrl) {
298299
setRedactModalFile({ ...file, url: cachedUrl });
299300
return;
@@ -317,7 +318,7 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
317318
alert("Failed to load file for redaction. Please try again.");
318319
}
319320
},
320-
[caseId, getCachedUrl, setCachedUrl],
321+
[caseId, getCachedUrl, setCachedUrl, preloadedUrls],
321322
);
322323

323324
const handlePreviewFile = useCallback(
@@ -455,10 +456,10 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
455456
prev.map((f) =>
456457
f.id === file.id
457458
? {
458-
...f,
459-
status: "ready" as FileStatus,
460-
conflictInfo: undefined,
461-
}
459+
...f,
460+
status: "ready" as FileStatus,
461+
conflictInfo: undefined,
462+
}
462463
: f,
463464
),
464465
);
@@ -780,11 +781,10 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
780781
<button
781782
key={category}
782783
onClick={() => setSelectedCategory(category)}
783-
className={`px-2.5 py-0.5 text-xs rounded-lg transition-colors ${
784-
selectedCategory === category
784+
className={`px-2.5 py-0.5 text-xs rounded-lg transition-colors ${selectedCategory === category
785785
? "bg-accent-light dark:bg-[#f5f4ef] text-cream dark:text-charcoal"
786786
: "bg-warm-gray/8 dark:bg-stone/10 text-muted-foreground hover:bg-warm-gray/12 dark:hover:bg-stone/15"
787-
}`}
787+
}`}
788788
>
789789
{category.charAt(0).toUpperCase() + category.slice(1)}
790790
</button>
@@ -928,11 +928,10 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
928928
<th className="w-12 px-4 py-3">
929929
<button
930930
onClick={toggleSelectAll}
931-
className={`flex items-center justify-center w-5 h-5 rounded border-2 transition-all ${
932-
selectionState === "none"
931+
className={`flex items-center justify-center w-5 h-5 rounded border-2 transition-all ${selectionState === "none"
933932
? "border-warm-gray/40 dark:border-stone/50 hover:border-warm-gray/60 dark:hover:border-stone/70"
934933
: "border-blue-500 bg-blue-500"
935-
}`}
934+
}`}
936935
title={
937936
selectionState === "all" ? "Deselect all" : "Select all"
938937
}
@@ -959,18 +958,16 @@ export function CaseLibrary({ caseId, caseName }: CaseLibraryProps) {
959958
initial={{ opacity: 0, y: 20 }}
960959
animate={{ opacity: 1, y: 0 }}
961960
transition={{ delay: index * 0.05 }}
962-
className={`border-b border-warm-gray/15 dark:border-stone/15 hover:bg-warm-gray/8 dark:hover:bg-stone/10 transition-colors ${
963-
selectedFileIds.has(file.id) ? "bg-blue-500/10" : ""
964-
}`}
961+
className={`border-b border-warm-gray/15 dark:border-stone/15 hover:bg-warm-gray/8 dark:hover:bg-stone/10 transition-colors ${selectedFileIds.has(file.id) ? "bg-blue-500/10" : ""
962+
}`}
965963
>
966964
<td className="w-12 px-4 py-4">
967965
<button
968966
onClick={() => toggleFileSelection(file.id)}
969-
className={`flex items-center justify-center w-5 h-5 rounded border-2 transition-all ${
970-
selectedFileIds.has(file.id)
967+
className={`flex items-center justify-center w-5 h-5 rounded border-2 transition-all ${selectedFileIds.has(file.id)
971968
? "border-blue-500 bg-blue-500"
972969
: "border-warm-gray/40 dark:border-stone/60 hover:border-warm-gray/60 dark:hover:border-stone/80"
973-
}`}
970+
}`}
974971
>
975972
{selectedFileIds.has(file.id) && (
976973
<Check className="w-3 h-3 text-white" />

frontend/src/components/library/RedactModal.tsx

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
144144
const [redactedImageUrl, setRedactedImageUrl] = useState<string | null>(null);
145145
const [redactedVideoUrl, setRedactedVideoUrl] = useState<string | null>(null);
146146
const [redactedAudioUrl, setRedactedAudioUrl] = useState<string | null>(null);
147+
const [isMediaLoaded, setIsMediaLoaded] = useState(false);
147148
// Visualization image is available but not currently displayed in the UI
148149
// const [visualizationImageUrl, setVisualizationImageUrl] = useState<string | null>(null);
149150
const [isDownloading, setIsDownloading] = useState(false);
@@ -153,6 +154,10 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
153154
const isVideo = file.type === "video";
154155
const isAudio = file.type === "audio";
155156

157+
useEffect(() => {
158+
setIsMediaLoaded(false);
159+
}, [file.url]);
160+
156161
const handleRedactionSubmit = useCallback(async () => {
157162
if (!redactionPrompt.trim()) return;
158163

@@ -510,11 +515,13 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
510515
src={file.url}
511516
className="w-full h-full"
512517
title={`Original: ${file.name}`}
518+
onLoad={() => setIsMediaLoaded(true)}
513519
/>
514520
) : isVideo ? (
515521
<div className="w-full h-full flex items-center justify-center bg-warm-gray/5 dark:bg-stone/5 p-4 overflow-hidden">
516522
<video
517523
src={file.url}
524+
onLoadedData={() => setIsMediaLoaded(true)}
518525
controls
519526
className="max-w-full max-h-full object-contain"
520527
title={`Original: ${file.name}`}
@@ -530,6 +537,7 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
530537
</p>
531538
<audio
532539
src={file.url}
540+
onLoadedData={() => setIsMediaLoaded(true)}
533541
controls
534542
className="w-full max-w-md"
535543
title={`Original: ${file.name}`}
@@ -543,6 +551,7 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
543551
fill
544552
className="object-contain"
545553
unoptimized
554+
onLoad={() => setIsMediaLoaded(true)}
546555
/>
547556
</div>
548557
)}
@@ -556,10 +565,10 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
556565
{/* Right Half - Redaction Controls or Preview */}
557566
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
558567
{status === "success" &&
559-
(redactedPdfUrl ||
560-
redactedImageUrl ||
561-
redactedVideoUrl ||
562-
redactedAudioUrl) ? (
568+
(redactedPdfUrl ||
569+
redactedImageUrl ||
570+
redactedVideoUrl ||
571+
redactedAudioUrl) ? (
563572
/* Redacted Preview */
564573
<div className="flex-1 relative min-h-0 overflow-hidden">
565574
{isPdf && redactedPdfUrl ? (
@@ -680,7 +689,8 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
680689
<div className="flex-1 flex items-center justify-center">
681690
<button
682691
onClick={() => setShowRedactionInput(true)}
683-
className="flex items-center space-x-3 px-6 py-4 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border-2 border-purple-500/30 hover:border-purple-500/50 transition-all"
692+
disabled={!isMediaLoaded}
693+
className="flex items-center space-x-3 px-6 py-4 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border-2 border-purple-500/30 hover:border-purple-500/50 transition-all disabled:opacity-50 disabled:blur-sm disabled:cursor-not-allowed"
684694
>
685695
<Sparkles className="w-6 h-6 text-purple-600 dark:text-purple-400" />
686696
<span className="text-lg font-medium text-purple-700 dark:text-purple-300">
@@ -731,22 +741,20 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
731741
<button
732742
type="button"
733743
onClick={() => setRedactionMethod("blur")}
734-
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${
735-
redactionMethod === "blur"
736-
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
737-
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
738-
}`}
744+
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${redactionMethod === "blur"
745+
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
746+
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
747+
}`}
739748
>
740749
Blur
741750
</button>
742751
<button
743752
type="button"
744753
onClick={() => setRedactionMethod("pixelate")}
745-
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${
746-
redactionMethod === "pixelate"
747-
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
748-
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
749-
}`}
754+
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${redactionMethod === "pixelate"
755+
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
756+
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
757+
}`}
750758
>
751759
Pixelate
752760
</button>
@@ -756,11 +764,10 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
756764
onClick={() =>
757765
setRedactionMethod("blackbox")
758766
}
759-
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${
760-
redactionMethod === "blackbox"
761-
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
762-
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
763-
}`}
767+
className={`flex-1 px-4 py-2 rounded-lg border transition-colors ${redactionMethod === "blackbox"
768+
? "border-purple-500 bg-purple-500/10 text-purple-700 dark:text-purple-300"
769+
: "border-warm-gray/15 dark:border-stone/15 text-muted-foreground hover:bg-warm-gray/10 dark:hover:bg-stone/10"
770+
}`}
764771
>
765772
Blackbox
766773
</button>
@@ -856,13 +863,13 @@ export function RedactModal({ isOpen, onClose, file }: RedactModalProps) {
856863
</span>
857864
{imageRedactionResult.categories_selected.length >
858865
0 && (
859-
<span className="text-muted-foreground ml-2">
860-
{" "}
861-
{imageRedactionResult.categories_selected.join(
862-
", ",
863-
)}
864-
</span>
865-
)}
866+
<span className="text-muted-foreground ml-2">
867+
{" "}
868+
{imageRedactionResult.categories_selected.join(
869+
", ",
870+
)}
871+
</span>
872+
)}
866873
</>
867874
) : isVideo && videoRedactionResult ? (
868875
<>

frontend/src/hooks/useFileUrlCache.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// ABOUTME: Hook for caching signed file URLs to avoid regenerating them repeatedly
22
// ABOUTME: URLs are cached for 1 hour (well before the 24h expiration)
3+
// ABOUTME: Persists cache to localStorage to survive page reloads
34

4-
import { useCallback, useRef } from "react";
5+
import { useCallback } from "react";
56

67
interface CachedUrl {
78
url: string;
@@ -14,18 +15,65 @@ interface UrlCache {
1415
}
1516

1617
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
18+
const STORAGE_KEY = "holmes_file_url_cache";
1719

18-
export function useFileUrlCache() {
19-
const cacheRef = useRef<UrlCache>({});
20+
// Module-level cache to share state between hook instances and avoid frequent localStorage reads
21+
let memoryCache: UrlCache | null = null;
22+
23+
function getMemoryCache(): UrlCache {
24+
if (memoryCache) return memoryCache;
2025

26+
if (typeof window === "undefined") {
27+
return {};
28+
}
29+
30+
try {
31+
const item = localStorage.getItem(STORAGE_KEY);
32+
if (item) {
33+
memoryCache = JSON.parse(item);
34+
// Prune expired items on load
35+
const now = Date.now();
36+
let changed = false;
37+
Object.keys(memoryCache!).forEach((key) => {
38+
const entry = memoryCache![key];
39+
if (now - entry.timestamp > CACHE_DURATION) {
40+
delete memoryCache![key];
41+
changed = true;
42+
}
43+
});
44+
if (changed) {
45+
localStorage.setItem(STORAGE_KEY, JSON.stringify(memoryCache));
46+
}
47+
} else {
48+
memoryCache = {};
49+
}
50+
} catch (e) {
51+
console.warn("Failed to load cache from localStorage", e);
52+
memoryCache = {};
53+
}
54+
55+
return memoryCache!;
56+
}
57+
58+
function saveToStorage(cache: UrlCache) {
59+
if (typeof window === "undefined") return;
60+
try {
61+
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
62+
} catch (e) {
63+
console.warn("Failed to save cache to localStorage", e);
64+
}
65+
}
66+
67+
export function useFileUrlCache() {
2168
const getCacheKey = useCallback((caseId: string, fileId: string) => {
2269
return `${caseId}:${fileId}`;
2370
}, []);
2471

2572
const getCachedUrl = useCallback(
2673
(caseId: string, fileId: string): string | null => {
74+
const cache = getMemoryCache();
2775
const key = getCacheKey(caseId, fileId);
28-
const cached = cacheRef.current[key];
76+
const cached = cache[key];
2977

3078
if (!cached) return null;
3179

@@ -38,7 +86,8 @@ export function useFileUrlCache() {
3886
}
3987

4088
// Cache expired, remove it
41-
delete cacheRef.current[key];
89+
delete cache[key];
90+
saveToStorage(cache);
4291
return null;
4392
},
4493
[getCacheKey],
@@ -51,24 +100,37 @@ export function useFileUrlCache() {
51100
url: string,
52101
expiresIn: number = 86400,
53102
) => {
103+
const cache = getMemoryCache();
54104
const key = getCacheKey(caseId, fileId);
55-
cacheRef.current[key] = {
105+
cache[key] = {
56106
url,
57107
timestamp: Date.now(),
58108
expiresIn,
59109
};
110+
saveToStorage(cache);
60111
},
61112
[getCacheKey],
62113
);
63114

64115
const clearCache = useCallback(() => {
65-
cacheRef.current = {};
116+
memoryCache = {};
117+
if (typeof window !== "undefined") {
118+
try {
119+
localStorage.removeItem(STORAGE_KEY);
120+
} catch (e) {
121+
console.warn("Failed to clear cache from localStorage", e);
122+
}
123+
}
66124
}, []);
67125

68126
const clearCacheForFile = useCallback(
69127
(caseId: string, fileId: string) => {
128+
const cache = getMemoryCache();
70129
const key = getCacheKey(caseId, fileId);
71-
delete cacheRef.current[key];
130+
if (cache[key]) {
131+
delete cache[key];
132+
saveToStorage(cache);
133+
}
72134
},
73135
[getCacheKey],
74136
);

0 commit comments

Comments
 (0)