Skip to content
Open
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
Empty file.
60 changes: 60 additions & 0 deletions frontend/src/components/LazyImage/LazyImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useRef, useState } from "react";
import { useIntersectionObserver } from "../../hooks/useIntersectionObserver";

interface LazyImageProps {
src: string;
alt: string;
className?: string;
rootMargin?: string;
threshold?: number;
}

export function LazyImage({
src,
alt,
className = "",
rootMargin = "200px",
threshold = 0.1,
}: LazyImageProps) {

const containerRef = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(containerRef, {
rootMargin,
threshold,
});

const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);

return (
<div
ref={containerRef}
className={`relative overflow-hidden ${className}`}
>
{/* Skeleton loader */}
{!isLoaded && !hasError && (
<div className="absolute inset-0 animate-pulse bg-muted" />
)}

{/* Image */}
{isVisible && !hasError && (
<img
src={src}
alt={alt}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isLoaded ? "opacity-100" : "opacity-0"
}`}
onLoad={() => setIsLoaded(true)}
onError={() => setHasError(true)}
/>
)}

{/* Error state */}
{hasError && (
<div className="flex h-full w-full items-center justify-center bg-muted text-xs text-muted-foreground">
Failed to load image
</div>
)}
</div>
);
}
11 changes: 9 additions & 2 deletions frontend/src/components/Media/ImageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Image } from '@/types/Media';
import { ImageTags } from './ImageTags';
import { convertFileSrc } from '@tauri-apps/api/core';
import { useToggleFav } from '@/hooks/useToggleFav';
import { LazyImage } from "../LazyImage/LazyImage";

interface ImageCardViewProps {
image: Image;
Expand Down Expand Up @@ -54,11 +55,17 @@ export function ImageCard({
)}

<AspectRatio ratio={1}>
<img
<LazyImage
src={convertFileSrc(
image.thumbnailPath || image.path || '/placeholder.svg',
)}
alt={'Sample Title'}
alt={
image.path
? image.path.split('/').pop() ?? 'Image thumbnail'
: 'Image thumbnail'
}


className={cn(
'h-full w-full object-cover transition-transform group-hover:scale-105',
isSelected ? 'opacity-95' : '',
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/components/Media/MediaThumbnails.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { LazyImage } from "../LazyImage/LazyImage";

interface MediaThumbnailsProps {
images: Array<{
Expand Down Expand Up @@ -116,16 +117,12 @@ export const MediaThumbnails: React.FC<MediaThumbnailsProps> = ({
: 'opacity-70 hover:opacity-100'
} cursor-pointer transition-all duration-200 hover:scale-105`}
>
<img
src={convertFileSrc(image.thumbnailPath) || '/placeholder.svg'}
alt={`thumbnail-${index}`}
className="h-full w-full object-cover"
onError={(e) => {
const img = e.target as HTMLImageElement;
img.onerror = null;
img.src = '/placeholder.svg';
}}
/>
<LazyImage
src={convertFileSrc(image.thumbnailPath) || '/placeholder.svg'}
alt={`thumbnail-${index}`}
className="h-full w-full object-cover"
/>
Comment on lines +120 to +124
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find convertFileSrc definition
echo "=== Searching for convertFileSrc definition ==="
rg -n "export.*convertFileSrc|function convertFileSrc" --type=ts --type=tsx

# Search in common locations
echo -e "\n=== Checking common utility files ==="
fd "convertFileSrc|file.*src" -e ts -e tsx | head -20

# Search for imports of convertFileSrc
echo -e "\n=== Searching for convertFileSrc imports ==="
rg "import.*convertFileSrc" --type=ts --type=tsx | head -20

Repository: AOSSIE-Org/PictoPy

Length of output: 267


🏁 Script executed:

#!/bin/bash
# Adjust search - don't use tsx file type
echo "=== Searching for convertFileSrc definition ==="
rg -n "export.*convertFileSrc|function convertFileSrc" 

echo -e "\n=== Find all .tsx and .ts files containing convertFileSrc ==="
rg -l "convertFileSrc" | head -20

echo -e "\n=== Check MediaThumbnails.tsx imports ==="
head -30 frontend/src/components/Media/MediaThumbnails.tsx

echo -e "\n=== Search for convertFileSrc usage patterns ==="
rg -B2 -A2 "convertFileSrc" | head -50

Repository: AOSSIE-Org/PictoPy

Length of output: 4836


🏁 Script executed:

#!/bin/bash
# Check LazyImage component
echo "=== LazyImage component implementation ==="
find . -name "LazyImage*" -type f
cat ./frontend/src/components/LazyImage/LazyImage.tsx

echo -e "\n=== Check ImageCard pattern more closely ==="
rg -B5 -A5 "thumbnailPath || image.path" frontend/src/components/Media/ImageCard.tsx

echo -e "\n=== Check if there are any comments about convertFileSrc behavior ==="
rg -B3 -A3 "convertFileSrc" frontend/src/hooks/UseVideos.ts

Repository: AOSSIE-Org/PictoPy

Length of output: 5878


🏁 Script executed:

#!/bin/bash
# Search for any test files that might show convertFileSrc behavior
echo "=== Search for convertFileSrc tests or edge cases ==="
find . -name "*.test.*" -o -name "*.spec.*" | xargs grep -l "convertFileSrc" 2>/dev/null

echo -e "\n=== Check if MediaThumbnails or related files have any error handling ==="
rg -B10 -A10 "convertFileSrc.*placeholder" frontend/src/components/Media/MediaThumbnails.tsx

echo -e "\n=== Search for any validation of paths before convertFileSrc ==="
rg -B5 "convertFileSrc" frontend/src/components/Media/MediaThumbnails.tsx | head -40

echo -e "\n=== Check if there's any conditional logic around thumbnailPath ==="
rg "thumbnailPath" frontend/src/components/Media/MediaThumbnails.tsx -B2 -A2

Repository: AOSSIE-Org/PictoPy

Length of output: 1636


🌐 Web query:

Tauri convertFileSrc behavior empty string null invalid path return value

💡 Result:

Short answer

  • convertFileSrc simply encodes the given string path and returns a protocol URL (e.g. asset://<url‑encoded‑path> on Unix, http(s)://asset.localhost/<url‑encoded‑path> on Windows). It does not check that the file exists. [1][2]
  • Because it only transforms the input string, calling it on an already‑converted URL will double‑encode/ nest the protocol (e.g. asset://asset%3A%2F%2F...); this is a known source of bugs. [3]
  • The API always returns a string URL for the supplied input; invalid filesystem paths produce a valid (but pointing‑nowhere) asset URL rather than an error. The function expects a string input — passing non‑strings (null/undefined) is a type mismatch in JS/TS and can produce unexpected results (coercion to "null"/"undefined" or a runtime error depending on usage). [1][2][3]

Sources

  • convertFileSrc doc in Tauri API (description, params, return type). [1]
  • Rust/wasm wrappers and tauri_sys docs showing convert_file_src semantics (returns Url; no existence check). [2]
  • GitHub issue describing double‑conversion problem and examples. [3]

References
[1] Tauri convertFileSrc (API docs).
[2] tauri convert_file_src (tauri_sys / tauri-wasm docs).
[3] tauri-apps/tauri issue: "Avoid calling convertFileSrc twice" (discussing double‑encoding).


Simplify the fallback logic for consistency and clarity.

The fallback pattern convertFileSrc(image.thumbnailPath) || '/placeholder.svg' is ineffective because convertFileSrc always returns a truthy string value (a protocol URL like asset://), even for invalid or empty paths. The || '/placeholder.svg' branch will never execute.

For consistency with the pattern used elsewhere in the codebase (e.g., ImageCard), place the fallback before converting:

src={convertFileSrc(image.thumbnailPath || '/placeholder.svg')}

This ensures that if thumbnailPath is empty or invalid, the placeholder is used before conversion.

Note: The error handling UX has changed—LazyImage now displays "Failed to load image" text on load errors rather than swapping to the placeholder image. Ensure this aligns with your design requirements.

🤖 Prompt for AI Agents
In @frontend/src/components/Media/MediaThumbnails.tsx around lines 120 - 124,
The src fallback is ineffective because convertFileSrc(image.thumbnailPath)
always returns a truthy string; update the LazyImage src to compute the fallback
before conversion by passing thumbnailPath || '/placeholder.svg' into
convertFileSrc (i.e., call convertFileSrc with the fallback applied to
image.thumbnailPath) so the placeholder is used when thumbnailPath is empty,
keeping the change localized to the LazyImage prop where convertFileSrc and
image.thumbnailPath are referenced; verify this aligns with LazyImage's new
error UX that shows "Failed to load image" on errors.


</div>
))}
</div>
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/hooks/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useState, RefObject } from "react";

interface IntersectionOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number;
}

/**
* Hook to detect when an element enters the viewport
*/
export function useIntersectionObserver(
ref: RefObject<Element | null>,
options: IntersectionOptions = {}
) {
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
const element = ref.current;
if (!element || isVisible) return;

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(element);
}
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? "200px",
threshold: options.threshold ?? 0.1,
}
);

observer.observe(element);

return () => observer.disconnect();
}, [ref, isVisible, options.root, options.rootMargin, options.threshold]);

return isVisible;
}