Przeglądaj źródła

feat(ui): rewrite ShareModal with link management

- Replace single share URL logic with multiple share links support
- Add list of active links with view count and expiration time
- Add copy to clipboard functionality with per-link feedback
- Add revoke button for each link (authenticated users)
- Add create new link section with expiration dropdown (auth users)
- Add simple create link button for non-auth users (max 1 link)
- Use new share link API functions from ../api/shareLinks
- Use SHARE_LINK_EXPIRY_OPTIONS from types
Fszontagh 1 dzień temu
rodzic
commit
79938ed86b
1 zmienionych plików z 160 dodań i 150 usunięć
  1. 160 150
      src/components/ShareModal.tsx

+ 160 - 150
src/components/ShareModal.tsx

@@ -1,7 +1,8 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } from 'react';
 import { useBaaS } from '@picobaas/client/react';
 import { useBaaS } from '@picobaas/client/react';
-import type { ImageFile } from '../types';
-import { EXPIRY_OPTIONS, PUBLIC_BUCKET, USER_BUCKET } from '../config';
+import type { ImageFile, ShareLink } from '../types';
+import { SHARE_LINK_EXPIRY_OPTIONS } from '../types';
+import { createShareLink, listShareLinks, revokeShareLink } from '../api/shareLinks';
 import LoadingSpinner from './LoadingSpinner';
 import LoadingSpinner from './LoadingSpinner';
 
 
 interface ShareModalProps {
 interface ShareModalProps {
@@ -11,105 +12,102 @@ interface ShareModalProps {
 }
 }
 
 
 export default function ShareModal({ isOpen, onClose, image }: ShareModalProps) {
 export default function ShareModal({ isOpen, onClose, image }: ShareModalProps) {
-  const { client } = useBaaS();
-  const [shareUrl, setShareUrl] = useState<string | null>(null);
-  const [expiresIn, setExpiresIn] = useState(86400); // 24h default
-  const [isGenerating, setIsGenerating] = useState(false);
-  const [copied, setCopied] = useState(false);
+  const { client, user } = useBaaS();
+  const [links, setLinks] = useState<ShareLink[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const [isCreating, setIsCreating] = useState(false);
+  const [expiresIn, setExpiresIn] = useState(604800); // 7 days default
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
+  const [copied, setCopied] = useState<string | null>(null);
+
+  const isAuthenticated = !!user;
+  const activeLinks = links.filter(l => l.isValid && !l.isRevoked);
+  const maxLinks = 10;
+
+  const loadLinks = useCallback(async () => {
+    if (!image.shortCode) return;
+    setIsLoading(true);
+    try {
+      const data = await listShareLinks(image.shortCode, client.accessToken || undefined);
+      setLinks(data);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to load links');
+    } finally {
+      setIsLoading(false);
+    }
+  }, [image.shortCode, client.accessToken]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpen) {
     if (isOpen) {
-      generateShareUrl();
+      loadLinks();
     }
     }
-  }, [isOpen, expiresIn]);
+  }, [isOpen, loadLinks]);
 
 
-  const generateShareUrl = async () => {
-    setIsGenerating(true);
+  const handleCreateLink = async () => {
+    if (!image.shortCode) return;
+    setIsCreating(true);
     setError(null);
     setError(null);
-
     try {
     try {
-      // Use the correct bucket based on where the image is stored
-      const bucketName = image.isPublic ? PUBLIC_BUCKET : USER_BUCKET;
-      const bucket = client.storage.from(bucketName);
-
-      if (image.isPublic) {
-        // 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);
-        }
-      } else {
-        // Private images get a signed URL
-        const { data, error: signError } = await bucket.createSignedUrl(
-          image.path,
-          expiresIn
-        );
-
-        if (signError) throw signError;
-        setShareUrl(data?.signedUrl || null);
-      }
+      await createShareLink(image.shortCode, expiresIn, client.accessToken || undefined);
+      await loadLinks();
     } catch (err) {
     } catch (err) {
-      setError(err instanceof Error ? err.message : 'Failed to generate link');
+      setError(err instanceof Error ? err.message : 'Failed to create link');
     } finally {
     } finally {
-      setIsGenerating(false);
+      setIsCreating(false);
     }
     }
   };
   };
 
 
-  const copyToClipboard = async () => {
-    if (!shareUrl) return;
+  const handleRevokeLink = async (linkCode: string) => {
+    if (!image.shortCode) return;
+    try {
+      await revokeShareLink(image.shortCode, linkCode, client.accessToken || undefined);
+      await loadLinks();
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to revoke link');
+    }
+  };
 
 
+  const copyToClipboard = async (url: string, linkCode: string) => {
     try {
     try {
-      await navigator.clipboard.writeText(shareUrl);
-      setCopied(true);
-      setTimeout(() => setCopied(false), 2000);
+      await navigator.clipboard.writeText(url);
+      setCopied(linkCode);
+      setTimeout(() => setCopied(null), 2000);
     } catch {
     } catch {
-      // Fallback for older browsers
       const textarea = document.createElement('textarea');
       const textarea = document.createElement('textarea');
-      textarea.value = shareUrl;
+      textarea.value = url;
       document.body.appendChild(textarea);
       document.body.appendChild(textarea);
       textarea.select();
       textarea.select();
       document.execCommand('copy');
       document.execCommand('copy');
       document.body.removeChild(textarea);
       document.body.removeChild(textarea);
-      setCopied(true);
-      setTimeout(() => setCopied(false), 2000);
+      setCopied(linkCode);
+      setTimeout(() => setCopied(null), 2000);
     }
     }
   };
   };
 
 
+  const formatExpiry = (date: Date) => {
+    const now = new Date();
+    const diff = date.getTime() - now.getTime();
+    if (diff < 0) return 'Expired';
+    if (diff < 3600000) return `${Math.floor(diff / 60000)}m left`;
+    if (diff < 86400000) return `${Math.floor(diff / 3600000)}h left`;
+    return `${Math.floor(diff / 86400000)}d left`;
+  };
+
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
   return (
   return (
     <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
     <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
-      {/* Backdrop */}
-      <div
-        className="absolute inset-0 bg-black/50"
-        onClick={onClose}
-      />
-
-      {/* Modal */}
+      <div className="absolute inset-0 bg-black/50" onClick={onClose} />
       <div
       <div
-        className="relative rounded-xl shadow-xl max-w-md w-full p-6"
+        className="relative rounded-xl shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto"
         style={{ backgroundColor: 'var(--bg-secondary)' }}
         style={{ backgroundColor: 'var(--bg-secondary)' }}
       >
       >
         {/* Header */}
         {/* Header */}
         <div className="flex items-center justify-between mb-4">
         <div className="flex items-center justify-between mb-4">
-          <h3
-            className="text-lg font-semibold"
-            style={{ color: 'var(--text-primary)' }}
-          >
+          <h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
             Share Image
             Share Image
           </h3>
           </h3>
-          <button
-            onClick={onClose}
-            className="transition-colors"
-            style={{ color: 'var(--text-muted)' }}
-          >
+          <button onClick={onClose} style={{ color: 'var(--text-muted)' }}>
             <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
             <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" />
               <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
             </svg>
             </svg>
@@ -118,94 +116,106 @@ export default function ShareModal({ isOpen, onClose, image }: ShareModalProps)
 
 
         {/* Image preview */}
         {/* Image preview */}
         <div className="mb-4">
         <div className="mb-4">
-          <img
-            src={image.url}
-            alt={image.name}
-            className="w-full h-32 object-cover rounded-lg"
-          />
+          <img src={image.url} alt={image.name} className="w-full h-32 object-cover rounded-lg" />
         </div>
         </div>
 
 
-        {/* Link expiry selector (for signed URLs) */}
-        {!image.isPublic && (
-          <div className="mb-4">
-            <label
-              className="block text-sm font-medium mb-2"
-              style={{ color: 'var(--text-secondary)' }}
-            >
-              Link expires in
-            </label>
-            <select
-              value={expiresIn}
-              onChange={(e) => setExpiresIn(Number(e.target.value))}
-              className="input"
-            >
-              {EXPIRY_OPTIONS.filter(o => o.value > 0).map((option) => (
-                <option key={option.value} value={option.value}>
-                  {option.label}
-                </option>
-              ))}
-            </select>
+        {error && (
+          <div className="mb-4 p-3 rounded-lg bg-red-500/10 text-red-600 text-sm">
+            {error}
           </div>
           </div>
         )}
         )}
 
 
-        {/* Share URL */}
-        <div className="mb-4">
-          <label
-            className="block text-sm font-medium mb-2"
-            style={{ color: 'var(--text-secondary)' }}
-          >
-            Share link
-          </label>
-          {isGenerating ? (
-            <div
-              className="flex items-center justify-center h-10 rounded-lg"
-              style={{ backgroundColor: 'var(--bg-tertiary)' }}
-            >
-              <LoadingSpinner size="sm" />
-            </div>
-          ) : error ? (
-            <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
-          ) : (
-            <div className="flex gap-2">
-              <input
-                type="text"
-                value={shareUrl || ''}
-                readOnly
-                className="input flex-1 text-sm"
-                style={{ backgroundColor: 'var(--bg-tertiary)' }}
-              />
-              <button
-                onClick={copyToClipboard}
-                className={`btn ${copied ? 'btn-primary' : 'btn-secondary'} whitespace-nowrap`}
-              >
-                {copied ? (
-                  <>
-                    <svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
-                      <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
-                    </svg>
-                    Copied!
-                  </>
-                ) : (
-                  'Copy'
-                )}
-              </button>
-            </div>
-          )}
-        </div>
-
-        {/* Direct download link */}
-        {shareUrl && (
-          <a
-            href={shareUrl}
-            target="_blank"
-            rel="noopener noreferrer"
-            className="btn btn-secondary w-full flex items-center justify-center gap-2"
-          >
-            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
-            </svg>
-            Open in new tab
-          </a>
+        {isLoading ? (
+          <div className="flex justify-center py-8">
+            <LoadingSpinner size="lg" />
+          </div>
+        ) : (
+          <>
+            {/* Active Links */}
+            {activeLinks.length > 0 && (
+              <div className="mb-6">
+                <h4 className="text-sm font-medium mb-3" style={{ color: 'var(--text-secondary)' }}>
+                  Active Links ({activeLinks.length} of {maxLinks})
+                </h4>
+                <div className="space-y-2">
+                  {activeLinks.map(link => (
+                    <div
+                      key={link.id}
+                      className="flex items-center gap-2 p-3 rounded-lg"
+                      style={{ backgroundColor: 'var(--bg-tertiary)' }}
+                    >
+                      <div className="flex-1 min-w-0">
+                        <input
+                          type="text"
+                          value={link.shareUrl}
+                          readOnly
+                          className="w-full text-xs bg-transparent border-none outline-none"
+                          style={{ color: 'var(--text-primary)' }}
+                        />
+                        <div className="flex gap-3 mt-1 text-xs" style={{ color: 'var(--text-muted)' }}>
+                          <span>{link.viewCount} views</span>
+                          <span>{formatExpiry(link.expiresAt)}</span>
+                        </div>
+                      </div>
+                      <button
+                        onClick={() => copyToClipboard(link.shareUrl, link.linkCode)}
+                        className={`btn text-xs px-2 py-1 ${copied === link.linkCode ? 'btn-primary' : 'btn-secondary'}`}
+                      >
+                        {copied === link.linkCode ? 'Copied!' : 'Copy'}
+                      </button>
+                      <button
+                        onClick={() => handleRevokeLink(link.linkCode)}
+                        className="text-red-500 hover:text-red-400 p-1"
+                        title="Revoke link"
+                      >
+                        <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                        </svg>
+                      </button>
+                    </div>
+                  ))}
+                </div>
+              </div>
+            )}
+
+            {/* Create New Link */}
+            {(isAuthenticated && 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
+                </h4>
+                <div className="flex gap-3">
+                  <select
+                    value={expiresIn}
+                    onChange={(e) => setExpiresIn(Number(e.target.value))}
+                    className="input flex-1"
+                  >
+                    {SHARE_LINK_EXPIRY_OPTIONS.map(opt => (
+                      <option key={opt.value} value={opt.value}>{opt.label}</option>
+                    ))}
+                  </select>
+                  <button
+                    onClick={handleCreateLink}
+                    disabled={isCreating}
+                    className="btn btn-primary whitespace-nowrap"
+                  >
+                    {isCreating ? <LoadingSpinner size="sm" /> : 'Create Link'}
+                  </button>
+                </div>
+              </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.
+                </p>
+                <button onClick={handleCreateLink} disabled={isCreating} className="btn btn-primary">
+                  {isCreating ? <LoadingSpinner size="sm" /> : 'Create Share Link'}
+                </button>
+              </div>
+            )}
+          </>
         )}
         )}
       </div>
       </div>
     </div>
     </div>