Просмотр исходного кода

feat: Use PicoBaaS SDK for share links API

- Rewrote shareLinks.ts to use SDK's QueryBuilder pattern
- Updated ShareModal, SharedImagePage, AnalyticsDashboard to pass client
- Allows anonymous users to create up to 3 share links
- Share page now uses SDK to look up links and increment view count
Fszontagh 16 часов назад
Родитель
Сommit
096a64ef83

+ 118 - 166
src/api/shareLinks.ts

@@ -1,7 +1,8 @@
 import type { ShareLink } from '../types';
+import type { BaaSClient } from '@picobaas/client';
 
 /**
- * Share links API using the generic CRUD endpoints.
+ * Share links API using the PicoBaaS SDK.
  * Share links are stored in the `share_links` project table.
  *
  * Table schema:
@@ -18,7 +19,19 @@ import type { ShareLink } from '../types';
  * - created_at: string (ISO timestamp)
  */
 
-const API_BASE = '/api';
+interface ShareLinkRecord {
+  id: string;
+  link_code: string;
+  image_id: string;
+  image_short_code: string;
+  created_by?: string;
+  session_token?: string;
+  expires_at: string;
+  view_count: number;
+  last_viewed_at?: string;
+  is_revoked: boolean;
+  created_at: string;
+}
 
 function generateLinkCode(): string {
   const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
@@ -29,216 +42,155 @@ function generateLinkCode(): string {
   return result;
 }
 
+function recordToShareLink(record: ShareLinkRecord): ShareLink {
+  const now = new Date();
+  const expiresAt = new Date(record.expires_at);
+  const isExpired = expiresAt < now;
+  const isRevoked = record.is_revoked;
+
+  return {
+    id: record.id,
+    linkCode: record.link_code,
+    shareUrl: `${window.location.origin}/#/s/${record.link_code}`,
+    expiresAt,
+    viewCount: record.view_count || 0,
+    lastViewedAt: record.last_viewed_at ? new Date(record.last_viewed_at) : undefined,
+    isRevoked,
+    isValid: !isRevoked && !isExpired,
+    createdAt: new Date(record.created_at),
+  };
+}
+
 export async function createShareLink(
+  client: BaaSClient,
   shortCode: string,
   expiresIn: number,
-  token?: string,
   sessionToken?: string,
   imageId?: string
-): Promise<{ shareUrl: string; linkCode: string; expiresAt: string }> {
-  const headers: Record<string, string> = {
-    'Content-Type': 'application/json',
-  };
-  if (token) {
-    headers['Authorization'] = `Bearer ${token}`;
-  }
-  if (sessionToken) {
-    headers['X-Session-Token'] = sessionToken;
-  }
-
+): Promise<ShareLink> {
   const linkCode = generateLinkCode();
   const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
 
-  // Create share link via CRUD API
-  const res = await fetch(`${API_BASE}/share_links`, {
-    method: 'POST',
-    headers,
-    body: JSON.stringify({
-      link_code: linkCode,
-      image_id: imageId || '',
-      image_short_code: shortCode,
-      expires_at: expiresAt,
-      view_count: 0,
-      is_revoked: false,
-      session_token: sessionToken || '',
-    }),
-  });
-
-  if (!res.ok) {
-    const error = await res.json().catch(() => ({}));
-    throw new Error(error.error || 'Failed to create share link');
-  }
+  const data: Partial<ShareLinkRecord> = {
+    link_code: linkCode,
+    image_id: imageId || '',
+    image_short_code: shortCode,
+    expires_at: expiresAt,
+    view_count: 0,
+    is_revoked: false,
+    session_token: sessionToken || '',
+  };
 
-  const data = await res.json();
-  const shareUrl = `${window.location.origin}/s/${linkCode}`;
+  // If authenticated, the SDK will include the auth header automatically
+  const response = await client.from<ShareLinkRecord>('share_links').insert(data);
 
+  if (response.error) {
+    throw new Error(response.error.message || 'Failed to create share link');
+  }
+
+  // Return the created share link
   return {
-    shareUrl,
+    id: response.data?.id || linkCode,
     linkCode,
-    expiresAt: data.expires_at || expiresAt,
+    shareUrl: `${window.location.origin}/#/s/${linkCode}`,
+    expiresAt: new Date(expiresAt),
+    viewCount: 0,
+    isRevoked: false,
+    isValid: true,
+    createdAt: new Date(),
   };
 }
 
 export async function listShareLinks(
-  shortCode: string,
-  token?: string,
-  sessionToken?: string
+  client: BaaSClient,
+  shortCode: string
 ): Promise<ShareLink[]> {
-  const headers: Record<string, string> = {};
-  if (token) {
-    headers['Authorization'] = `Bearer ${token}`;
+  const response = await client
+    .from<ShareLinkRecord>('share_links')
+    .select('*')
+    .eq('image_short_code', shortCode)
+    .order('created_at', { ascending: false })
+    .get();
+
+  if (response.error) {
+    throw new Error(response.error.message || 'Failed to load share links');
   }
-  if (sessionToken) {
-    headers['X-Session-Token'] = sessionToken;
-  }
-
-  // Query share links filtered by image_short_code
-  const params = new URLSearchParams({
-    filter: `image_short_code.eq.${shortCode}`,
-    order: 'created_at.desc',
-  });
-
-  const res = await fetch(`${API_BASE}/share_links?${params}`, { headers });
-
-  if (!res.ok) {
-    throw new Error('Failed to load share links');
-  }
-
-  const data = await res.json();
-  const now = new Date();
 
-  return (data.data || []).map((link: Record<string, unknown>) => {
-    const expiresAt = new Date(link.expires_at as string);
-    const isExpired = expiresAt < now;
-    const isRevoked = link.is_revoked as boolean;
-
-    return {
-      id: link.id as string,
-      linkCode: link.link_code as string,
-      shareUrl: `${window.location.origin}/s/${link.link_code}`,
-      expiresAt,
-      viewCount: (link.view_count as number) || 0,
-      lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
-      isRevoked,
-      isValid: !isRevoked && !isExpired,
-      createdAt: new Date(link.created_at as string),
-    };
-  });
+  return (response.data || []).map(recordToShareLink);
 }
 
 export async function revokeShareLink(
-  _shortCode: string, // Kept for API compatibility, but not used with CRUD approach
-  linkCode: string,
-  token?: string,
-  sessionToken?: string
+  client: BaaSClient,
+  linkCode: string
 ): Promise<void> {
-  const headers: Record<string, string> = {
-    'Content-Type': 'application/json',
-  };
-  if (token) {
-    headers['Authorization'] = `Bearer ${token}`;
-  }
-  if (sessionToken) {
-    headers['X-Session-Token'] = sessionToken;
-  }
-
-  // First find the link ID by link_code
-  const params = new URLSearchParams({
-    filter: `link_code.eq.${linkCode}`,
-    limit: '1',
-  });
-
-  const findRes = await fetch(`${API_BASE}/share_links?${params}`, { headers });
-  if (!findRes.ok) {
-    throw new Error('Failed to find share link');
-  }
-
-  const findData = await findRes.json();
-  if (!findData.data || findData.data.length === 0) {
+  // First find the link by link_code
+  const findResponse = await client
+    .from<ShareLinkRecord>('share_links')
+    .select('id')
+    .eq('link_code', linkCode)
+    .limit(1)
+    .get();
+
+  if (findResponse.error || !findResponse.data || findResponse.data.length === 0) {
     throw new Error('Share link not found');
   }
 
-  const linkId = findData.data[0].id;
+  const linkId = findResponse.data[0].id;
 
   // Update the link to mark it as revoked
-  const res = await fetch(`${API_BASE}/share_links/${linkId}`, {
-    method: 'PUT',
-    headers,
-    body: JSON.stringify({
-      is_revoked: true,
-    }),
-  });
-
-  if (!res.ok) {
-    throw new Error('Failed to revoke share link');
+  const updateResponse = await client
+    .from<ShareLinkRecord>('share_links')
+    .eq('id', linkId)
+    .update({ is_revoked: true });
+
+  if (updateResponse.error) {
+    throw new Error(updateResponse.error.message || 'Failed to revoke share link');
   }
 }
 
 export async function getShareLinkByCode(
+  client: BaaSClient,
   linkCode: string
 ): Promise<ShareLink | null> {
-  const params = new URLSearchParams({
-    filter: `link_code.eq.${linkCode}`,
-    limit: '1',
-  });
-
-  const res = await fetch(`${API_BASE}/share_links?${params}`);
-
-  if (!res.ok) {
-    return null;
-  }
-
-  const data = await res.json();
-  if (!data.data || data.data.length === 0) {
+  const response = await client
+    .from<ShareLinkRecord>('share_links')
+    .select('*')
+    .eq('link_code', linkCode)
+    .limit(1)
+    .get();
+
+  if (response.error || !response.data || response.data.length === 0) {
     return null;
   }
 
-  const link = data.data[0];
-  const now = new Date();
-  const expiresAt = new Date(link.expires_at as string);
-  const isExpired = expiresAt < now;
-  const isRevoked = link.is_revoked as boolean;
-
-  return {
-    id: link.id as string,
-    linkCode: link.link_code as string,
-    shareUrl: `${window.location.origin}/s/${link.link_code}`,
-    expiresAt,
-    viewCount: (link.view_count as number) || 0,
-    lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
-    isRevoked,
-    isValid: !isRevoked && !isExpired,
-    createdAt: new Date(link.created_at as string),
-  };
+  return recordToShareLink(response.data[0]);
 }
 
 export async function incrementViewCount(
+  client: BaaSClient,
   linkCode: string
 ): Promise<void> {
   // Find the link
-  const params = new URLSearchParams({
-    filter: `link_code.eq.${linkCode}`,
-    limit: '1',
-  });
-
-  const findRes = await fetch(`${API_BASE}/share_links?${params}`);
-  if (!findRes.ok) return;
-
-  const findData = await findRes.json();
-  if (!findData.data || findData.data.length === 0) return;
+  const findResponse = await client
+    .from<ShareLinkRecord>('share_links')
+    .select('id,view_count')
+    .eq('link_code', linkCode)
+    .limit(1)
+    .get();
+
+  if (findResponse.error || !findResponse.data || findResponse.data.length === 0) {
+    return;
+  }
 
-  const link = findData.data[0];
-  const currentCount = (link.view_count as number) || 0;
+  const link = findResponse.data[0];
+  const currentCount = link.view_count || 0;
 
   // Update view count
-  await fetch(`${API_BASE}/share_links/${link.id}`, {
-    method: 'PUT',
-    headers: {
-      'Content-Type': 'application/json',
-    },
-    body: JSON.stringify({
+  await client
+    .from<ShareLinkRecord>('share_links')
+    .eq('id', link.id)
+    .update({
       view_count: currentCount + 1,
       last_viewed_at: new Date().toISOString(),
-    }),
-  });
+    });
 }

+ 1 - 3
src/components/AnalyticsDashboard.tsx

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
 import { useBaaS } from '@picobaas/client/react';
 import type { ImageAnalytics, ViewLog, ShareLink } from '../types';
 import { listShareLinks } from '../api/shareLinks';
-import { getSessionToken } from '../utils/session';
 import LoadingSpinner from './LoadingSpinner';
 
 interface ImageInfo {
@@ -39,11 +38,10 @@ export default function AnalyticsDashboard({ shortCode, onClose }: AnalyticsDash
       };
 
       // Fetch image info, analytics, and share links in parallel
-      const sessionToken = getSessionToken();
       const [imageResponse, analyticsResponse, links] = await Promise.all([
         fetch(`/api/images/${shortCode}`, { headers }),
         fetch(`/api/images/${shortCode}/analytics`, { headers }),
-        listShareLinks(shortCode, token || undefined, sessionToken),
+        listShareLinks(client, shortCode),
       ]);
 
       // Handle image info

+ 22 - 19
src/components/ShareModal.tsx

@@ -1,9 +1,9 @@
 import { useState, useEffect, useCallback } from 'react';
 import { useBaaS, useAuth } from '@picobaas/client/react';
 import type { ImageFile, ShareLink } from '../types';
-import { SHARE_LINK_EXPIRY_OPTIONS } from '../types';
+import { SHARE_LINK_EXPIRY_OPTIONS, ANON_EXPIRY_OPTIONS } from '../types';
 import { createShareLink, listShareLinks, revokeShareLink } from '../api/shareLinks';
-import { getSessionToken } from '../utils/session';
+import { getSessionToken } from '../utils/session'; // Still needed for anonymous session tracking
 import LoadingSpinner from './LoadingSpinner';
 
 interface ShareModalProps {
@@ -22,21 +22,22 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
   const [error, setError] = useState<string | null>(null);
   const [copied, setCopied] = useState<string | null>(null);
   const activeLinks = links.filter(l => l.isValid && !l.isRevoked);
-  const maxLinks = 10;
+  // Anonymous users can create up to 3 links, authenticated up to 10
+  const maxLinks = isAuthenticated ? 10 : 3;
+  const expiryOptions = isAuthenticated ? SHARE_LINK_EXPIRY_OPTIONS : ANON_EXPIRY_OPTIONS;
 
   const loadLinks = useCallback(async () => {
     if (!image.shortCode) return;
     setIsLoading(true);
     try {
-      const sessionToken = getSessionToken();
-      const data = await listShareLinks(image.shortCode, client.accessToken || undefined, sessionToken);
+      const data = await listShareLinks(client, image.shortCode);
       setLinks(data);
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to load links');
     } finally {
       setIsLoading(false);
     }
-  }, [image.shortCode, client.accessToken]);
+  }, [image.shortCode, client]);
 
   useEffect(() => {
     if (isOpen) {
@@ -50,7 +51,7 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
     setError(null);
     try {
       const sessionToken = getSessionToken();
-      await createShareLink(image.shortCode, expiresIn, client.accessToken || undefined, sessionToken, image.id);
+      await createShareLink(client, image.shortCode, expiresIn, sessionToken, image.id);
       await loadLinks();
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to create link');
@@ -62,8 +63,7 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
   const handleRevokeLink = async (linkCode: string) => {
     if (!image.shortCode) return;
     try {
-      const sessionToken = getSessionToken();
-      await revokeShareLink(image.shortCode, linkCode, client.accessToken || undefined, sessionToken);
+      await revokeShareLink(client, linkCode);
       await loadLinks();
     } catch (err) {
       setError(err instanceof Error ? err.message : 'Failed to revoke link');
@@ -182,10 +182,10 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
             )}
 
             {/* Create New Link */}
-            {(isAuthenticated && activeLinks.length < maxLinks) && (
+            {activeLinks.length < maxLinks && (
               <div className="border-t pt-4" style={{ borderColor: 'var(--border)' }}>
                 <h4 className="text-sm font-medium mb-3" style={{ color: 'var(--text-secondary)' }}>
-                  Create New Link
+                  Create New Link {!isAuthenticated && `(${activeLinks.length}/${maxLinks})`}
                 </h4>
                 <div className="flex gap-3">
                   <select
@@ -193,7 +193,7 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
                     onChange={(e) => setExpiresIn(Number(e.target.value))}
                     className="input flex-1"
                   >
-                    {SHARE_LINK_EXPIRY_OPTIONS.map(opt => (
+                    {expiryOptions.map(opt => (
                       <option key={opt.value} value={opt.value}>{opt.label}</option>
                     ))}
                   </select>
@@ -205,17 +205,20 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
                     {isCreating ? <LoadingSpinner size="sm" /> : 'Create Link'}
                   </button>
                 </div>
+                {!isAuthenticated && (
+                  <p className="text-xs mt-2" style={{ color: 'var(--text-muted)' }}>
+                    Sign up for more links and shorter expiry options.
+                  </p>
+                )}
               </div>
             )}
 
-            {!isAuthenticated && activeLinks.length === 0 && (
-              <div className="text-center py-4">
-                <p className="text-sm mb-3" style={{ color: 'var(--text-muted)' }}>
-                  No share link created yet.
+            {activeLinks.length >= maxLinks && (
+              <div className="text-center py-4 border-t" style={{ borderColor: 'var(--border)' }}>
+                <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
+                  Maximum links reached ({maxLinks}).
+                  {!isAuthenticated && ' Sign up for more links.'}
                 </p>
-                <button onClick={handleCreateLink} disabled={isCreating} className="btn btn-primary">
-                  {isCreating ? <LoadingSpinner size="sm" /> : 'Create Share Link'}
-                </button>
               </div>
             )}
           </>

+ 25 - 0
src/pages/HomePage.tsx

@@ -4,6 +4,7 @@ import { useAuth } from '@picobaas/client/react';
 import { useImages } from '../hooks/useImages';
 import { PUBLIC_BUCKET } from '../config';
 import ImageUploader from '../components/ImageUploader';
+import ShareModal from '../components/ShareModal';
 import { formatExpiryStatus } from '../utils/formatters';
 import type { ImageFile, UploadOptions } from '../types';
 
@@ -19,6 +20,7 @@ export default function HomePage() {
     error?: string;
   } | null>(null);
   const [copied, setCopied] = useState(false);
+  const [showShareModal, setShowShareModal] = useState(false);
 
   const handleUpload = async (file: File, options: UploadOptions) => {
     setIsUploading(true);
@@ -179,6 +181,20 @@ export default function HomePage() {
 
               {/* Action buttons */}
               <div className="mt-4 flex flex-col sm:flex-row items-center justify-center gap-3">
+                {uploadResult.image?.shortCode && (
+                  <>
+                    <button
+                      onClick={() => setShowShareModal(true)}
+                      className="text-green-700 dark:text-green-400 hover:opacity-80 text-sm font-medium flex items-center gap-1"
+                    >
+                      <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
+                      </svg>
+                      Create Share Links
+                    </button>
+                    <span className="hidden sm:inline text-gray-300 dark:text-gray-600">|</span>
+                  </>
+                )}
                 <a
                   href={uploadResult.url}
                   target="_blank"
@@ -348,6 +364,15 @@ export default function HomePage() {
           </p>
         </div>
       </div>
+
+      {/* Share Modal */}
+      {uploadResult?.image && (
+        <ShareModal
+          isOpen={showShareModal}
+          onClose={() => setShowShareModal(false)}
+          image={uploadResult.image}
+        />
+      )}
     </div>
   );
 }

+ 120 - 29
src/pages/SharedImagePage.tsx

@@ -1,18 +1,103 @@
+import { useEffect, useState } from 'react';
 import { useParams, Link } from 'react-router-dom';
+import { useBaaS } from '@picobaas/client/react';
+import { getShareLinkByCode, incrementViewCount } from '../api/shareLinks';
+import type { ShareLink } from '../types';
+import LoadingSpinner from '../components/LoadingSpinner';
 
 export default function SharedImagePage() {
   const { shareId } = useParams<{ shareId: string }>();
+  const { client } = useBaaS();
+  const [shareLink, setShareLink] = useState<ShareLink | null>(null);
+  const [imageUrl, setImageUrl] = useState<string | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [imageLoadError, setImageLoadError] = useState(false);
 
-  // The shareId is the signed URL path
-  // This page provides a nice wrapper around the direct image link
-  const imageUrl = shareId ? `/storage/${decodeURIComponent(shareId)}` : null;
+  useEffect(() => {
+    async function loadShareLink() {
+      if (!shareId) {
+        setError('Invalid share link');
+        setIsLoading(false);
+        return;
+      }
 
-  if (!imageUrl) {
+      try {
+        // Look up the share link using SDK
+        const link = await getShareLinkByCode(client, shareId);
+
+        if (!link) {
+          setError('Share link not found');
+          setIsLoading(false);
+          return;
+        }
+
+        setShareLink(link);
+
+        // Check if valid
+        if (link.isRevoked) {
+          setError('This share link has been revoked');
+          setIsLoading(false);
+          return;
+        }
+
+        if (!link.isValid) {
+          setError('This share link has expired');
+          setIsLoading(false);
+          return;
+        }
+
+        // Fetch the share link data to get the image_short_code using SDK
+        const linkDataRes = await client
+          .from<{ image_short_code: string }>('share_links')
+          .select('image_short_code')
+          .eq('link_code', shareId)
+          .limit(1)
+          .get();
+
+        if (!linkDataRes.error && linkDataRes.data && linkDataRes.data.length > 0) {
+          const imageShortCode = linkDataRes.data[0].image_short_code;
+          // Use direct storage path with short code
+          setImageUrl(`/i/${imageShortCode}`);
+        }
+
+        // Increment view count using SDK (fire and forget)
+        incrementViewCount(client, shareId).catch(console.warn);
+
+        setIsLoading(false);
+      } catch (err) {
+        console.error('Error loading share link:', err);
+        setError('Failed to load shared image');
+        setIsLoading(false);
+      }
+    }
+
+    loadShareLink();
+  }, [shareId, client]);
+
+  if (isLoading) {
+    return (
+      <div className="min-h-screen bg-gray-900 flex items-center justify-center">
+        <LoadingSpinner size="lg" />
+      </div>
+    );
+  }
+
+  if (error || !imageUrl) {
     return (
       <div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
         <div className="text-center text-white">
-          <h1 className="text-2xl font-bold mb-4">Image not found</h1>
-          <p className="text-gray-400 mb-6">This link may have expired or is invalid.</p>
+          <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-900/30 mb-6">
+            <svg className="h-8 w-8 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+            </svg>
+          </div>
+          <h1 className="text-2xl font-bold mb-4">
+            {error || 'Image not available'}
+          </h1>
+          <p className="text-gray-400 mb-6">
+            This share link may have expired, been revoked, or the image was deleted.
+          </p>
           <Link to="/" className="btn btn-primary">
             Go to Home
           </Link>
@@ -64,33 +149,39 @@ export default function SharedImagePage() {
       {/* Image */}
       <main className="flex-1 flex items-center justify-center p-4">
         <div className="max-w-5xl w-full">
-          <img
-            src={imageUrl}
-            alt="Shared image"
-            className="w-full h-auto max-h-[80vh] object-contain rounded-lg shadow-2xl"
-            onError={(e) => {
-              // Handle expired or invalid images
-              const target = e.target as HTMLImageElement;
-              target.style.display = 'none';
-              target.parentElement!.innerHTML = `
-                <div class="text-center text-white py-20">
-                  <p class="text-xl mb-4">This image is no longer available</p>
-                  <p class="text-gray-400">The link may have expired or the image was deleted.</p>
-                </div>
-              `;
-            }}
-          />
+          {imageLoadError ? (
+            <div className="text-center text-white py-20">
+              <p className="text-xl mb-4">This image is no longer available</p>
+              <p className="text-gray-400">The link may have expired or the image was deleted.</p>
+            </div>
+          ) : (
+            <img
+              src={imageUrl}
+              alt="Shared image"
+              className="w-full h-auto max-h-[80vh] object-contain rounded-lg shadow-2xl"
+              onError={() => setImageLoadError(true)}
+            />
+          )}
         </div>
       </main>
 
-      {/* Footer */}
+      {/* Footer with view count */}
       <footer className="bg-gray-800 border-t border-gray-700 py-4 px-6">
-        <div className="max-w-7xl mx-auto text-center text-gray-400 text-sm">
-          Shared via{' '}
-          <Link to="/" className="text-primary-400 hover:text-primary-300">
-            ImageDrop
-          </Link>
-          {' '}- Upload and share images instantly
+        <div className="max-w-7xl mx-auto flex justify-between items-center text-gray-400 text-sm">
+          <span>
+            {shareLink && shareLink.viewCount > 0 && (
+              <span className="mr-4">
+                {shareLink.viewCount + 1} views
+              </span>
+            )}
+          </span>
+          <span>
+            Shared via{' '}
+            <Link to="/" className="text-primary-400 hover:text-primary-300">
+              ImageDrop
+            </Link>
+            {' '}- Upload and share images instantly
+          </span>
         </div>
       </footer>
     </div>