286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import React, { useState } from "react";
|
|
import {
|
|
Share2,
|
|
Calendar,
|
|
Download,
|
|
Copy,
|
|
Check,
|
|
Loader2,
|
|
Clock,
|
|
Shield,
|
|
ExternalLink,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Modal, CustomButton } from "./";
|
|
import { useAppTheme } from "@/hooks/useAppTheme";
|
|
import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service";
|
|
import { DeleteConfirmationModal } from "./DeleteConfirmationModal";
|
|
|
|
interface FileShareModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
file: FileAttachment;
|
|
}
|
|
|
|
export const FileShareModal: React.FC<FileShareModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
file,
|
|
}) => {
|
|
const [expiresInHours, setExpiresInHours] = useState<number>(24);
|
|
const [maxDownloads, setMaxDownloads] = useState<number | "">("");
|
|
const permissions = "download";
|
|
const { primaryColor } = useAppTheme();
|
|
|
|
const [isSharing, setIsSharing] = useState(false);
|
|
const [shareData, setShareData] = useState<{ url: string; token: string; id: string } | null>(null);
|
|
const [isRevoking, setIsRevoking] = useState(false);
|
|
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCreateShare = async () => {
|
|
setIsSharing(true);
|
|
try {
|
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api/v1";
|
|
const res = await fileAttachmentService.createShare(file.id, {
|
|
share_type: "link",
|
|
permissions,
|
|
expires_in_hours: expiresInHours || undefined,
|
|
max_downloads: maxDownloads === "" ? null : Number(maxDownloads),
|
|
});
|
|
|
|
const fullUrl = `${baseUrl}/files/shared/${res.data.share_token}`;
|
|
setShareData({ url: fullUrl, token: res.data.share_token, id: res.data.id });
|
|
} catch (error) {
|
|
console.error("Failed to share:", error);
|
|
} finally {
|
|
setIsSharing(false);
|
|
}
|
|
};
|
|
|
|
const handleRevokeShare = async () => {
|
|
if (!shareData) return;
|
|
|
|
setIsRevoking(true);
|
|
try {
|
|
await fileAttachmentService.revokeShare(shareData.id);
|
|
setShareData(null);
|
|
setShowRevokeConfirm(false);
|
|
} catch (error) {
|
|
console.error("Failed to revoke share:", error);
|
|
alert("Failed to revoke share link. Please try again.");
|
|
} finally {
|
|
setIsRevoking(false);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = async () => {
|
|
if (!shareData) return;
|
|
try {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(shareData.url);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} else {
|
|
throw new Error("Clipboard API unavailable");
|
|
}
|
|
} catch (err) {
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = shareData.url;
|
|
textArea.style.position = "fixed";
|
|
textArea.style.left = "-9999px";
|
|
textArea.style.top = "0";
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
try {
|
|
document.execCommand("copy");
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (copyErr) {
|
|
console.error("Fallback copy failed:", copyErr);
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title="Share File"
|
|
description={file.original_name}
|
|
maxWidth="md"
|
|
preventCloseOnClickOutside={showRevokeConfirm}
|
|
>
|
|
<div className="p-6 space-y-6">
|
|
{!shareData ? (
|
|
<>
|
|
{/* Expiry */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
|
<Clock className="w-3 h-3" />
|
|
Expires In
|
|
</label>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{[1, 24, 72, 168].map((h) => (
|
|
<button
|
|
key={h}
|
|
type="button"
|
|
onClick={() => setExpiresInHours(h)}
|
|
className={cn(
|
|
"py-2 text-xs font-medium rounded-lg border transition-all cursor-pointer",
|
|
expiresInHours === h
|
|
? "text-white shadow-sm"
|
|
: "border-[rgba(0,0,0,0.08)] text-[#475569] hover:bg-gray-50"
|
|
)}
|
|
style={expiresInHours === h ? {
|
|
backgroundColor: primaryColor,
|
|
borderColor: primaryColor
|
|
} : {}}
|
|
>
|
|
{h === 1 ? "1 Hr" : h === 24 ? "1 Day" : h === 72 ? "3 Days" : "7 Days"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Max Downloads */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
|
<Download className="w-3 h-3" />
|
|
Max Downloads
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={maxDownloads}
|
|
onChange={(e) => setMaxDownloads(e.target.value === "" ? "" : Number(e.target.value))}
|
|
placeholder="Unlimited"
|
|
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm focus:outline-none focus:ring-1 transition-all"
|
|
style={{
|
|
'--tw-ring-color': primaryColor,
|
|
borderColor: 'rgba(0,0,0,0.12)'
|
|
} as React.CSSProperties}
|
|
/>
|
|
</div>
|
|
|
|
{/* Permissions */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
|
|
<Shield className="w-3 h-3" />
|
|
Permission
|
|
</label>
|
|
<div className="w-full h-10 border border-[rgba(0,0,0,0.08)] bg-gray-50/50 rounded-lg px-3 flex items-center gap-2 text-sm text-[#475569] font-medium">
|
|
<Download className="w-3.5 h-3.5 text-[#9aa6b2]" />
|
|
Download
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<CustomButton
|
|
variant="primary"
|
|
fullWidth
|
|
size="lg"
|
|
onClick={handleCreateShare}
|
|
isLoading={isSharing}
|
|
leftIcon={<Share2 className="w-4 h-4" />}
|
|
className="rounded-xl shadow-lg"
|
|
style={{ boxShadow: `${primaryColor}33 0px 8px 24px` }}
|
|
>
|
|
Generate Secure Link
|
|
</CustomButton>
|
|
</>
|
|
) : (
|
|
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2">
|
|
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
|
<Check className="w-4 h-4 text-emerald-600" />
|
|
</div>
|
|
<p className="text-sm font-bold text-emerald-800">Share Link Generated</p>
|
|
</div>
|
|
<p className="text-xs text-emerald-600/80 leading-relaxed">
|
|
Anyone with this link can {permissions === 'download' ? 'download' : 'view'} the file
|
|
until {new Date(Date.now() + expiresInHours * 3600000).toLocaleString()}.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] font-bold text-[#9aa6b2] uppercase tracking-widest">Share URL</label>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-11 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-xl px-3 flex items-center overflow-hidden">
|
|
<code
|
|
className="text-[11px] truncate font-mono"
|
|
style={{ color: primaryColor }}
|
|
>
|
|
{shareData.url}
|
|
</code>
|
|
</div>
|
|
<button
|
|
onClick={copyToClipboard}
|
|
className={cn(
|
|
"w-11 h-11 rounded-xl flex items-center justify-center transition-all cursor-pointer",
|
|
copied ? "bg-emerald-500 text-white shadow-emerald-500/30" : "text-white hover:scale-105"
|
|
)}
|
|
style={!copied ? { backgroundColor: primaryColor, boxShadow: `${primaryColor}4D 0px 4px 12px` } : {}}
|
|
>
|
|
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-2 flex flex-col gap-2">
|
|
<div className="flex gap-2">
|
|
<CustomButton
|
|
variant="outline"
|
|
className="flex-1 rounded-xl"
|
|
onClick={() => setShareData(null)}
|
|
>
|
|
Create Another
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="outline"
|
|
className="px-4 rounded-xl"
|
|
leftIcon={<ExternalLink className="w-3.5 h-3.5" />}
|
|
onClick={() => window.open(shareData.url, '_blank')}
|
|
>
|
|
Test Link
|
|
</CustomButton>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setShowRevokeConfirm(true)}
|
|
disabled={isRevoking}
|
|
className="w-full h-10 border border-red-100 bg-red-50/50 hover:bg-red-50 rounded-xl text-xs font-bold text-red-600 transition-all flex items-center justify-center gap-2"
|
|
>
|
|
{isRevoking ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
"Revoke Link (Stop Sharing)"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-4 mt-6 border-t border-[rgba(0,0,0,0.06)] flex items-center gap-2">
|
|
<Calendar className="w-3.5 h-3.5 text-[#9aa6b2]" />
|
|
<p className="text-[10px] text-[#9aa6b2] leading-tight">
|
|
Links are automatically revoked after expiration or reaching max downloads for security.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<DeleteConfirmationModal
|
|
isOpen={showRevokeConfirm}
|
|
onClose={() => setShowRevokeConfirm(false)}
|
|
onConfirm={handleRevokeShare}
|
|
title="Revoke Share Link"
|
|
message="Are you sure you want to revoke this share link? It will stop working immediately."
|
|
isLoading={isRevoking}
|
|
/>
|
|
</Modal>
|
|
);
|
|
};
|