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
187 changes: 187 additions & 0 deletions ticket-frontend/src/lib/components/image-preview.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<script lang="ts">
/**
* ImagePreview component for displaying images from URLs
* Includes error handling, loading states, and click-to-expand functionality
*/

interface Props {
urls: string[];
maxHeight?: number;
}

let { urls, maxHeight = 300 }: Props = $props();

interface ImageState {
url: string;
loading: boolean;
error: boolean;
expanded: boolean;
}

let images = $state<ImageState[]>([]);
let expandedIndex = $state<number | null>(null);

// Initialize image states when URLs change
$effect(() => {
images = urls.map(url => ({
url,
loading: true,
error: false,
expanded: false
}));
});

function handleImageLoad(index: number) {
images[index].loading = false;
images[index].error = false;
}

function handleImageError(index: number) {
images[index].loading = false;
images[index].error = true;
}

function toggleExpand(index: number) {
expandedIndex = expandedIndex === index ? null : index;
}

function closeExpanded() {
expandedIndex = null;
}

// Close expanded view on Escape key
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expandedIndex !== null) {
closeExpanded();
}
}
</script>

<svelte:window onkeydown={handleKeydown} />

{#if images.length > 0}
<div class="space-y-3 mt-3">
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Detected {images.length} {images.length === 1 ? 'image' : 'images'}</span>
</div>

<div class="grid grid-cols-1 gap-3">
{#each images as image, i}
<div class="relative rounded-lg border border-border overflow-hidden bg-muted/30">
{#if image.loading}
<div
class="flex items-center justify-center bg-muted/50"
style="height: {maxHeight}px"
>
<div class="flex flex-col items-center gap-2">
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
<span class="text-xs text-muted-foreground">Loading image...</span>
</div>
</div>
{/if}

{#if image.error}
<div
class="flex items-center justify-center bg-destructive/5"
style="height: {maxHeight}px"
>
<div class="flex flex-col items-center gap-2 p-4 text-center">
<svg class="w-10 h-10 text-destructive/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-xs text-destructive">Failed to load image</span>
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline break-all"
>
Open in new tab
</a>
</div>
</div>
{:else}
<button
type="button"
onclick={() => toggleExpand(i)}
class="w-full cursor-zoom-in hover:opacity-90 transition-opacity"
style="display: {image.loading ? 'none' : 'block'}"
>
<img
src={image.url}
alt="Ticket attachment"
onload={() => handleImageLoad(i)}
onerror={() => handleImageError(i)}
class="w-full h-auto object-contain"
style="max-height: {maxHeight}px"
/>
</button>

<!-- Image URL caption -->
<div class="px-3 py-2 bg-muted/50 border-t border-border">
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
class="text-xs text-primary hover:underline break-all flex items-center gap-1"
>
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span class="truncate">{image.url}</span>
</a>
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}

<!-- Expanded Image Modal -->
{#if expandedIndex !== null && !images[expandedIndex].error}
<div
class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onclick={closeExpanded}
role="button"
tabindex="0"
aria-label="Close expanded image"
>
<button
type="button"
onclick={closeExpanded}
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
aria-label="Close"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>

<div class="max-w-7xl max-h-[90vh] overflow-auto" onclick={(e) => e.stopPropagation()}>
<img
src={images[expandedIndex].url}
alt="Expanded ticket attachment"
class="w-auto h-auto max-w-full max-h-[90vh] object-contain"
/>
</div>

<div class="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black/70 text-white px-4 py-2 rounded-lg text-sm">
Click anywhere to close • ESC to exit
</div>
</div>
{/if}

<style>
/* Smooth loading animation */
@keyframes spin {
to { transform: rotate(360deg); }
}

.animate-spin {
animation: spin 1s linear infinite;
}
</style>
13 changes: 13 additions & 0 deletions ticket-frontend/src/lib/components/ticket-card.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { Ticket, TicketPriority } from '$lib/stores/tickets';
import { hasImageUrls } from '$lib/utils/imageDetection';

interface Props {
ticket: Ticket;
Expand All @@ -11,6 +12,11 @@

let { ticket, isSelected = false, isDraggable = true, onselect, ondragstart }: Props = $props();

// Check if ticket contains images
let containsImages = $derived(
hasImageUrls(ticket.title || '') || hasImageUrls(ticket.description || '')
);

const priorityStyles: Record<TicketPriority, string> = {
critical: 'bg-destructive/10 text-destructive border-destructive/30',
high: 'bg-warning/10 text-warning border-warning/30',
Expand Down Expand Up @@ -95,6 +101,13 @@
<span>{ticket.category.toLowerCase()}</span>
</div>
{/if}
{#if containsImages}
<div class="flex items-center gap-1 text-primary" title="Contains images">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
{#if ticket.aiReasoning}
<div class="flex items-center gap-1 text-accent" title="AI analysis available">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
Expand Down
14 changes: 14 additions & 0 deletions ticket-frontend/src/lib/components/ticket-detail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { users, currentUser } from '$lib/stores/users.ts';
import AiReasoningPanel from './ai-reasoning-panel.svelte';
import UnassignModal from './unassign-modal.svelte';
import ImagePreview from './image-preview.svelte';
import { extractImageUrls } from '$lib/utils/imageDetection';

interface Props {
ticket: Ticket;
Expand All @@ -20,6 +22,13 @@

let showUnassignModal = $state(false);
let pendingUnassignUser = $state<string | null>(null);

// Extract image URLs from ticket description and title
let imageUrls = $derived(() => {
const titleImages = extractImageUrls(ticket.title || '');
const descImages = extractImageUrls(ticket.description || '');
return [...titleImages, ...descImages];
});

$effect(() => {
editedTitle = ticket.title;
Expand Down Expand Up @@ -388,6 +397,11 @@
</button>
{/if}
</div>

<!-- Image Preview Section -->
{#if imageUrls().length > 0}
<ImagePreview urls={imageUrls()} maxHeight={300} />
{/if}
{/if}

</div>
Expand Down
116 changes: 116 additions & 0 deletions ticket-frontend/src/lib/utils/imageDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Utility functions for detecting and extracting image URLs from text
*/

// Common image extensions
const IMAGE_EXTENSIONS = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff', 'tif'
];

// Pattern to match image URLs
const IMAGE_URL_PATTERN = new RegExp(
`https?://[^\\s<>"{}|\\\\^[\`]+\\.(${IMAGE_EXTENSIONS.join('|')})(?:[?#][^\\s]*)?`,
'gi'
);

// Pattern to match URLs that might contain images (like your Google example)
const POTENTIAL_IMAGE_URL_PATTERN = /https?:\/\/[^\s<>"{}|\\^[\`]+/gi;

/**
* Extract all image URLs from text
* @param text - The text to search for image URLs
* @returns Array of unique image URLs found in the text
*/
export function extractImageUrls(text: string): string[] {
if (!text) return [];

const urls = new Set<string>();

// Match direct image URLs (with common image extensions)
const directMatches = text.match(IMAGE_URL_PATTERN);
if (directMatches) {
directMatches.forEach(url => urls.add(url));
}

// Match potential image URLs (including services like imgur, Google images, etc.)
const potentialMatches = text.match(POTENTIAL_IMAGE_URL_PATTERN);
if (potentialMatches) {
potentialMatches.forEach(url => {
// Check if URL is from known image hosting services
if (isLikelyImageUrl(url)) {
urls.add(url);
}
});
}

return Array.from(urls);
}

/**
* Check if a URL is likely to be an image based on domain or query parameters
* @param url - The URL to check
* @returns True if the URL is likely an image
*/
function isLikelyImageUrl(url: string): boolean {
const lowerUrl = url.toLowerCase();

// Known image hosting services
const imageHosts = [
'imgur.com',
'i.imgur.com',
'gstatic.com', // Google images
'googleusercontent.com',
'cloudinary.com',
'amazonaws.com', // S3
'cloudfront.net',
'flickr.com',
'staticflickr.com',
'photobucket.com',
'imageshack.com',
'tinypic.com',
'postimg.cc',
'imgbb.com',
'ibb.co',
'giphy.com',
'tenor.com'
];

// Check if URL contains image hosting domains
if (imageHosts.some(host => lowerUrl.includes(host))) {
return true;
}

// Check for image extensions
if (IMAGE_EXTENSIONS.some(ext => lowerUrl.includes(`.${ext}`))) {
return true;
}

return false;
}

/**
* Check if text contains any image URLs
* @param text - The text to check
* @returns True if the text contains at least one image URL
*/
export function hasImageUrls(text: string): boolean {
return extractImageUrls(text).length > 0;
}

/**
* Replace image URLs in text with placeholders
* Useful for displaying text without inline URLs
* @param text - The text to process
* @param placeholder - The placeholder text (default: "[Image]")
* @returns Text with image URLs replaced
*/
export function replaceImageUrls(text: string, placeholder: string = '[Image]'): string {
const imageUrls = extractImageUrls(text);
let result = text;

imageUrls.forEach(url => {
result = result.replace(url, placeholder);
});

return result;
}