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

feat: Add viewer analytics, SEO short URLs, and session-based delete

- Add session management for anonymous upload ownership tracking
- Implement download toggle checkbox in ImageUploader
- Update useImages hook to call /api/images endpoint for short URLs
- Add AnalyticsDashboard component for view statistics
- Create ImageAnalyticsPage with protected route
- Add analytics link to ImageCard
- Update ShareModal to prefer short URLs over raw storage URLs
- Add new types for analytics, upload options, and image metadata
Fszontagh 4 дней назад
Родитель
Сommit
05629c00a1

+ 9 - 0
src/App.tsx

@@ -8,6 +8,7 @@ import VerifyEmailPage from './pages/VerifyEmailPage';
 import DashboardPage from './pages/DashboardPage';
 import ImageDetailPage from './pages/ImageDetailPage';
 import SharedImagePage from './pages/SharedImagePage';
+import ImageAnalyticsPage from './pages/ImageAnalyticsPage';
 
 // Protected route wrapper
 function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -55,6 +56,14 @@ function App() {
             </ProtectedRoute>
           }
         />
+        <Route
+          path="analytics/:shortCode"
+          element={
+            <ProtectedRoute>
+              <ImageAnalyticsPage />
+            </ProtectedRoute>
+          }
+        />
       </Route>
 
       {/* Shared image view (no layout) */}

+ 224 - 0
src/components/AnalyticsDashboard.tsx

@@ -0,0 +1,224 @@
+import { useState, useEffect } from 'react';
+import { useBaaS } from '@picobaas/client/react';
+import type { ImageAnalytics, ViewLog } from '../types';
+import LoadingSpinner from './LoadingSpinner';
+
+interface AnalyticsDashboardProps {
+  shortCode: string;
+  onClose?: () => void;
+}
+
+export default function AnalyticsDashboard({ shortCode, onClose }: AnalyticsDashboardProps) {
+  const { client } = useBaaS();
+  const [analytics, setAnalytics] = useState<ImageAnalytics | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    fetchAnalytics();
+  }, [shortCode]);
+
+  const fetchAnalytics = async () => {
+    setIsLoading(true);
+    setError(null);
+
+    try {
+      // Get auth token from client
+      const token = await client.auth.getAccessToken();
+
+      const response = await fetch(`/api/images/${shortCode}/analytics`, {
+        headers: {
+          'Authorization': token ? `Bearer ${token}` : '',
+        },
+      });
+
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => ({}));
+        throw new Error(errorData.error || 'Failed to load analytics');
+      }
+
+      const data = await response.json();
+      setAnalytics({
+        totalViews: data.total_views,
+        uniqueVisitors: data.unique_visitors,
+        proxyViewsCount: data.proxy_views_count,
+        viewsByReferrer: data.views_by_referrer || {},
+        recentViews: (data.recent_views || []).map((v: Record<string, unknown>) => ({
+          id: v.id as string,
+          viewerIp: v.viewer_ip as string,
+          userAgent: v.user_agent as string,
+          referrer: v.referrer as string | null,
+          country: v.country as string | null,
+          isProxyDetected: v.is_proxy_detected as boolean,
+          viewedAt: new Date(v.viewed_at as string),
+        })),
+      });
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load analytics');
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center p-8">
+        <LoadingSpinner size="lg" />
+      </div>
+    );
+  }
+
+  if (error) {
+    return (
+      <div className="p-6 text-center">
+        <div className="text-red-600 mb-4">{error}</div>
+        <button onClick={fetchAnalytics} className="btn btn-secondary">
+          Try Again
+        </button>
+      </div>
+    );
+  }
+
+  if (!analytics) {
+    return null;
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      {onClose && (
+        <div className="flex justify-between items-center">
+          <h2 className="text-xl font-semibold text-gray-900">Image Analytics</h2>
+          <button
+            onClick={onClose}
+            className="text-gray-400 hover:text-gray-600"
+          >
+            <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+      )}
+
+      {/* Stats Grid */}
+      <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
+        <div className="bg-gray-50 rounded-lg p-4">
+          <div className="text-sm text-gray-500 mb-1">Total Views</div>
+          <div className="text-2xl font-bold text-gray-900">{analytics.totalViews}</div>
+        </div>
+        <div className="bg-gray-50 rounded-lg p-4">
+          <div className="text-sm text-gray-500 mb-1">Unique Visitors</div>
+          <div className="text-2xl font-bold text-gray-900">{analytics.uniqueVisitors}</div>
+        </div>
+        <div className="bg-gray-50 rounded-lg p-4">
+          <div className="text-sm text-gray-500 mb-1">Proxy/VPN Views</div>
+          <div className="text-2xl font-bold text-gray-900">{analytics.proxyViewsCount}</div>
+        </div>
+      </div>
+
+      {/* Referrers */}
+      {Object.keys(analytics.viewsByReferrer).length > 0 && (
+        <div>
+          <h3 className="text-sm font-medium text-gray-700 mb-3">Top Referrers</h3>
+          <div className="bg-gray-50 rounded-lg divide-y divide-gray-200">
+            {Object.entries(analytics.viewsByReferrer)
+              .sort(([, a], [, b]) => b - a)
+              .slice(0, 5)
+              .map(([referrer, count]) => (
+                <div key={referrer} className="px-4 py-3 flex justify-between items-center">
+                  <span className="text-sm text-gray-600 truncate max-w-xs" title={referrer}>
+                    {referrer}
+                  </span>
+                  <span className="text-sm font-medium text-gray-900">{count}</span>
+                </div>
+              ))}
+          </div>
+        </div>
+      )}
+
+      {/* Recent Views Table */}
+      <div>
+        <h3 className="text-sm font-medium text-gray-700 mb-3">Recent Views</h3>
+        {analytics.recentViews.length === 0 ? (
+          <p className="text-gray-500 text-sm">No views recorded yet</p>
+        ) : (
+          <div className="overflow-x-auto">
+            <table className="min-w-full divide-y divide-gray-200">
+              <thead className="bg-gray-50">
+                <tr>
+                  <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                    Time
+                  </th>
+                  <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                    IP Address
+                  </th>
+                  <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                    Referrer
+                  </th>
+                  <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+                    Proxy
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="bg-white divide-y divide-gray-200">
+                {analytics.recentViews.slice(0, 20).map((view: ViewLog) => (
+                  <tr key={view.id}>
+                    <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
+                      {formatDate(view.viewedAt)}
+                    </td>
+                    <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
+                      {view.viewerIp}
+                    </td>
+                    <td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate" title={view.referrer || undefined}>
+                      {view.referrer || '-'}
+                    </td>
+                    <td className="px-4 py-3 whitespace-nowrap text-sm">
+                      {view.isProxyDetected ? (
+                        <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
+                          Detected
+                        </span>
+                      ) : (
+                        <span className="text-gray-400">-</span>
+                      )}
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+
+      {/* Refresh Button */}
+      <div className="flex justify-end">
+        <button onClick={fetchAnalytics} className="btn btn-secondary text-sm">
+          Refresh
+        </button>
+      </div>
+    </div>
+  );
+}
+
+function formatDate(date: Date): string {
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+
+  if (diff < 60000) {
+    return 'Just now';
+  }
+  if (diff < 3600000) {
+    const mins = Math.floor(diff / 60000);
+    return `${mins}m ago`;
+  }
+  if (diff < 86400000) {
+    const hours = Math.floor(diff / 3600000);
+    return `${hours}h ago`;
+  }
+
+  return date.toLocaleDateString(undefined, {
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+  });
+}

+ 21 - 0
src/components/ImageCard.tsx

@@ -95,6 +95,27 @@ export default function ImageCard({
                 </svg>
                 Share
               </button>
+              {image.shortCode && (
+                <Link
+                  to={`/analytics/${image.shortCode}`}
+                  className="btn btn-secondary text-sm py-1.5 px-3"
+                  title="View Analytics"
+                >
+                  <svg
+                    className="h-4 w-4"
+                    fill="none"
+                    viewBox="0 0 24 24"
+                    stroke="currentColor"
+                  >
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
+                    />
+                  </svg>
+                </Link>
+              )}
               {onDelete && (
                 <button
                   onClick={handleDelete}

+ 26 - 2
src/components/ImageUploader.tsx

@@ -1,11 +1,13 @@
 import { useState, useCallback, useRef } from 'react';
 import ExpirySelector from './ExpirySelector';
 import LoadingSpinner from './LoadingSpinner';
+import type { UploadOptions } from '../types';
 
 interface ImageUploaderProps {
-  onUpload: (file: File, expirySeconds: number) => Promise<void>;
+  onUpload: (file: File, options: UploadOptions) => Promise<void>;
   isUploading?: boolean;
   showExpiry?: boolean;
+  showDownloadToggle?: boolean;
   compact?: boolean;
 }
 
@@ -13,12 +15,14 @@ export default function ImageUploader({
   onUpload,
   isUploading = false,
   showExpiry = true,
+  showDownloadToggle = true,
   compact = false,
 }: ImageUploaderProps) {
   const [dragActive, setDragActive] = useState(false);
   const [preview, setPreview] = useState<string | null>(null);
   const [selectedFile, setSelectedFile] = useState<File | null>(null);
   const [expirySeconds, setExpirySeconds] = useState(86400); // Default 24h
+  const [downloadAllowed, setDownloadAllowed] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const inputRef = useRef<HTMLInputElement>(null);
 
@@ -88,10 +92,14 @@ export default function ImageUploader({
     if (!selectedFile) return;
 
     try {
-      await onUpload(selectedFile, expirySeconds);
+      await onUpload(selectedFile, {
+        expirySeconds,
+        downloadAllowed,
+      });
       // Reset after successful upload
       setSelectedFile(null);
       setPreview(null);
+      setDownloadAllowed(true);
       if (inputRef.current) {
         inputRef.current.value = '';
       }
@@ -199,6 +207,22 @@ export default function ImageUploader({
             <ExpirySelector value={expirySeconds} onChange={setExpirySeconds} />
           )}
 
+          {/* Download toggle */}
+          {showDownloadToggle && (
+            <label className="flex items-center gap-3 cursor-pointer select-none">
+              <input
+                type="checkbox"
+                checked={downloadAllowed}
+                onChange={(e) => setDownloadAllowed(e.target.checked)}
+                className="w-5 h-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
+                disabled={isUploading}
+              />
+              <span className="text-sm text-gray-700">
+                Allow viewers to download this image
+              </span>
+            </label>
+          )}
+
           <div className="flex gap-3">
             <button
               onClick={handleUpload}

+ 6 - 2
src/components/ShareModal.tsx

@@ -32,10 +32,14 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
       const bucket = client.storage.from(PUBLIC_BUCKET);
 
       if (image.isPublic) {
-        // Public images: prefer alias URL if available, otherwise direct URL
-        if (image.aliasUrl) {
+        // Prefer SEO-friendly short URL if available
+        if (image.shortUrl) {
+          setShareUrl(`${window.location.origin}${image.shortUrl}`);
+        } else if (image.aliasUrl) {
+          // Fallback to alias URL
           setShareUrl(image.aliasUrl);
         } else {
+          // Last resort: direct storage URL
           const url = bucket.getPublicUrl(image.path).data.publicUrl;
           setShareUrl(url);
         }

+ 62 - 22
src/hooks/useImages.ts

@@ -1,8 +1,9 @@
 import { useState, useCallback, useEffect } from 'react';
 import { useBaaS, useAuth } from '@picobaas/client/react';
-import type { ImageFile } from '../types';
+import type { ImageFile, UploadOptions } from '../types';
 import { PUBLIC_BUCKET, USER_BUCKET, generateUUID } from '../config';
 import { getExpiryFromMetadata } from '../utils/expiry';
+import { getSessionToken, trackUpload, untrackUpload } from '../utils/session';
 import type { FileMetadata } from '@picobaas/client';
 
 export function useImages(bucket: string = PUBLIC_BUCKET) {
@@ -51,7 +52,7 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
   }, [client, bucket, userId]);
 
   const uploadImage = useCallback(
-    async (file: File, expirySeconds: number): Promise<ImageFile | null> => {
+    async (file: File, options: UploadOptions): Promise<ImageFile | null> => {
       try {
         const storage = client.storage.from(bucket);
 
@@ -63,11 +64,11 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
 
         // Calculate expiry
         const expiresAt =
-          expirySeconds > 0
-            ? new Date(Date.now() + expirySeconds * 1000).toISOString()
+          options.expirySeconds > 0
+            ? new Date(Date.now() + options.expirySeconds * 1000).toISOString()
             : undefined;
 
-        // Upload
+        // Upload to storage
         const { data, error: uploadError } = await storage.upload(path, file, {
           contentType: file.type,
           metadata: expiresAt ? { expires_at: expiresAt } : undefined,
@@ -75,18 +76,37 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
 
         if (uploadError) throw uploadError;
 
-        // Create a short URL alias for the uploaded file
-        let aliasId: string | undefined;
-        let aliasUrl: string | undefined;
-        try {
-          const aliasResult = await storage.createAlias(path, expiresAt);
-          if (aliasResult.data) {
-            aliasId = aliasResult.data.id;
-            aliasUrl = aliasResult.data.url;
+        // Register with image API for short URL and tracking
+        const sessionToken = getSessionToken();
+        const apiResponse = await fetch('/api/images', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            'X-Session-Token': sessionToken,
+          },
+          body: JSON.stringify({
+            bucket,
+            path,
+            storage_object_id: data?.id,
+            download_allowed: options.downloadAllowed,
+            expires_at: expiresAt,
+          }),
+        });
+
+        let shortCode: string | undefined;
+        let shortUrl: string | undefined;
+
+        if (apiResponse.ok) {
+          const imageData = await apiResponse.json();
+          shortCode = imageData.short_code;
+          shortUrl = `/i/${shortCode}`;
+
+          // Track anonymous uploads for deletion capability
+          if (!isAuthenticated && shortCode) {
+            trackUpload(shortCode);
           }
-        } catch (aliasErr) {
-          // Alias creation failed, but upload succeeded - continue without alias
-          console.warn('Failed to create alias:', aliasErr);
+        } else {
+          console.warn('Failed to register image with API:', await apiResponse.text());
         }
 
         const newImage: ImageFile = {
@@ -100,8 +120,9 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
           expiresAt: expiresAt ? new Date(expiresAt) : undefined,
           isPublic: bucket === PUBLIC_BUCKET,
           ownerId: userId || undefined,
-          aliasId,
-          aliasUrl,
+          shortCode,
+          shortUrl,
+          downloadAllowed: options.downloadAllowed,
         };
 
         setImages((prev) => [newImage, ...prev]);
@@ -112,16 +133,35 @@ export function useImages(bucket: string = PUBLIC_BUCKET) {
         );
       }
     },
-    [client, bucket, userId]
+    [client, bucket, userId, isAuthenticated]
   );
 
   const deleteImage = useCallback(
     async (image: ImageFile): Promise<void> => {
       try {
-        const storage = client.storage.from(bucket);
-        const { error: deleteError } = await storage.remove([image.path]);
+        // If we have a short code, delete via the image API (handles session auth)
+        if (image.shortCode) {
+          const sessionToken = getSessionToken();
+          const apiResponse = await fetch(`/api/images/${image.shortCode}`, {
+            method: 'DELETE',
+            headers: {
+              'X-Session-Token': sessionToken,
+            },
+          });
+
+          if (!apiResponse.ok) {
+            const errorData = await apiResponse.json().catch(() => ({}));
+            throw new Error(errorData.error || 'Delete failed');
+          }
 
-        if (deleteError) throw deleteError;
+          // Remove from session tracking
+          untrackUpload(image.shortCode);
+        } else {
+          // Fallback to direct storage deletion
+          const storage = client.storage.from(bucket);
+          const { error: deleteError } = await storage.remove([image.path]);
+          if (deleteError) throw deleteError;
+        }
 
         setImages((prev) => prev.filter((img) => img.id !== image.id));
       } catch (err) {

+ 3 - 3
src/pages/DashboardPage.tsx

@@ -3,7 +3,7 @@ import { useImages } from '../hooks/useImages';
 import { USER_BUCKET } from '../config';
 import ImageGrid from '../components/ImageGrid';
 import ImageUploader from '../components/ImageUploader';
-import type { ImageFile } from '../types';
+import type { ImageFile, UploadOptions } from '../types';
 
 export default function DashboardPage() {
   const { images, isLoading, error, uploadImage, deleteImage, fetchImages } = useImages(USER_BUCKET);
@@ -11,12 +11,12 @@ export default function DashboardPage() {
   const [showUploader, setShowUploader] = useState(false);
   const [uploadError, setUploadError] = useState<string | null>(null);
 
-  const handleUpload = async (file: File, expirySeconds: number) => {
+  const handleUpload = async (file: File, options: UploadOptions) => {
     setIsUploading(true);
     setUploadError(null);
 
     try {
-      await uploadImage(file, expirySeconds);
+      await uploadImage(file, options);
       setShowUploader(false);
     } catch (err) {
       setUploadError(err instanceof Error ? err.message : 'Upload failed');

+ 8 - 3
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 type { UploadOptions } from '../types';
 
 export default function HomePage() {
   const { isAuthenticated } = useAuth();
@@ -15,14 +16,18 @@ export default function HomePage() {
     error?: string;
   } | null>(null);
 
-  const handleUpload = async (file: File, expirySeconds: number) => {
+  const handleUpload = async (file: File, options: UploadOptions) => {
     setIsUploading(true);
     setUploadResult(null);
 
     try {
-      const image = await uploadImage(file, expirySeconds);
+      const image = await uploadImage(file, options);
       if (image) {
-        setUploadResult({ success: true, url: image.url });
+        // Use short URL if available, otherwise fall back to raw URL
+        const shareUrl = image.shortUrl
+          ? `${window.location.origin}${image.shortUrl}`
+          : image.url;
+        setUploadResult({ success: true, url: shareUrl });
       }
     } catch (err) {
       setUploadResult({

+ 49 - 0
src/pages/ImageAnalyticsPage.tsx

@@ -0,0 +1,49 @@
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import AnalyticsDashboard from '../components/AnalyticsDashboard';
+
+export default function ImageAnalyticsPage() {
+  const { shortCode } = useParams<{ shortCode: string }>();
+  const navigate = useNavigate();
+
+  if (!shortCode) {
+    return (
+      <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
+        <div className="text-center">
+          <h1 className="text-2xl font-bold text-gray-900 mb-4">Image not found</h1>
+          <Link to="/dashboard" className="btn btn-primary">
+            Back to Dashboard
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
+      {/* Breadcrumb */}
+      <nav className="mb-6">
+        <ol className="flex items-center space-x-2 text-sm text-gray-500">
+          <li>
+            <Link to="/dashboard" className="hover:text-gray-700">
+              Dashboard
+            </Link>
+          </li>
+          <li>
+            <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
+              <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
+            </svg>
+          </li>
+          <li className="font-medium text-gray-900">Analytics</li>
+        </ol>
+      </nav>
+
+      {/* Main content */}
+      <div className="card p-6">
+        <AnalyticsDashboard
+          shortCode={shortCode}
+          onClose={() => navigate('/dashboard')}
+        />
+      </div>
+    </div>
+  );
+}

+ 48 - 0
src/types/index.ts

@@ -12,6 +12,11 @@ export interface ImageFile {
   ownerId?: string;
   aliasId?: string;
   aliasUrl?: string;
+  // New fields for enhanced sharing
+  shortCode?: string;
+  shortUrl?: string;
+  downloadAllowed?: boolean;
+  viewCount?: number;
 }
 
 export interface UploadResult {
@@ -30,3 +35,46 @@ export interface ExpiryOption {
   label: string;
   value: number; // seconds, 0 = never
 }
+
+// Upload options for new images
+export interface UploadOptions {
+  expirySeconds: number;
+  downloadAllowed: boolean;
+  title?: string;
+  description?: string;
+}
+
+// View log entry for analytics
+export interface ViewLog {
+  id: string;
+  viewerIp: string;
+  userAgent: string;
+  referrer: string | null;
+  country: string | null;
+  isProxyDetected: boolean;
+  viewedAt: Date;
+}
+
+// Analytics data for an image
+export interface ImageAnalytics {
+  totalViews: number;
+  uniqueVisitors: number;
+  proxyViewsCount: number;
+  viewsByReferrer: Record<string, number>;
+  recentViews: ViewLog[];
+}
+
+// Image metadata from API
+export interface ImageMetadata {
+  id: string;
+  shortCode: string;
+  bucket: string;
+  path: string;
+  downloadAllowed: boolean;
+  isPublic: boolean;
+  viewCount: number;
+  imageUrl: string;
+  createdAt: string;
+  expiresAt: string | null;
+  canDelete: boolean;
+}

+ 90 - 0
src/utils/session.ts

@@ -0,0 +1,90 @@
+/**
+ * Session management for anonymous upload ownership
+ * Tracks uploads in localStorage so anonymous users can delete their own images
+ */
+
+const SESSION_KEY = 'imagedrop_session';
+const UPLOADS_KEY = 'imagedrop_uploads';
+
+interface SessionData {
+  token: string;
+  createdAt: number;
+}
+
+interface UploadRecord {
+  shortCode: string;
+  createdAt: number;
+}
+
+/**
+ * Generate or retrieve session token
+ * Used to identify anonymous users for delete permissions
+ */
+export function getSessionToken(): string {
+  const stored = localStorage.getItem(SESSION_KEY);
+  if (stored) {
+    try {
+      const data: SessionData = JSON.parse(stored);
+      return data.token;
+    } catch {
+      // Invalid data, regenerate
+    }
+  }
+
+  // Generate new token
+  const token = crypto.randomUUID();
+  const data: SessionData = { token, createdAt: Date.now() };
+  localStorage.setItem(SESSION_KEY, JSON.stringify(data));
+  return token;
+}
+
+/**
+ * Track an anonymous upload by short code
+ */
+export function trackUpload(shortCode: string): void {
+  const uploads = getTrackedUploads();
+  // Avoid duplicates
+  if (!uploads.some(u => u.shortCode === shortCode)) {
+    uploads.push({ shortCode, createdAt: Date.now() });
+    localStorage.setItem(UPLOADS_KEY, JSON.stringify(uploads));
+  }
+}
+
+/**
+ * Get all tracked uploads for this session
+ */
+export function getTrackedUploads(): UploadRecord[] {
+  const stored = localStorage.getItem(UPLOADS_KEY);
+  if (stored) {
+    try {
+      return JSON.parse(stored);
+    } catch {
+      return [];
+    }
+  }
+  return [];
+}
+
+/**
+ * Check if the current session owns this upload
+ */
+export function isOwnUpload(shortCode: string): boolean {
+  const uploads = getTrackedUploads();
+  return uploads.some(u => u.shortCode === shortCode);
+}
+
+/**
+ * Remove an upload from tracking (after deletion)
+ */
+export function untrackUpload(shortCode: string): void {
+  const uploads = getTrackedUploads().filter(u => u.shortCode !== shortCode);
+  localStorage.setItem(UPLOADS_KEY, JSON.stringify(uploads));
+}
+
+/**
+ * Clear all session data
+ */
+export function clearSession(): void {
+  localStorage.removeItem(SESSION_KEY);
+  localStorage.removeItem(UPLOADS_KEY);
+}