|
@@ -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>
|