veza/apps/web/src/components/share/ShareLinkManager.tsx

360 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import {
Share2,
Copy,
Check,
Trash2,
Plus,
ExternalLink,
Calendar,
Eye,
Loader2,
} from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { formatDistanceToNow, format } from 'date-fns';
import { fr } from 'date-fns/locale';
import { cn } from '@/lib/utils';
/**
* FE-COMP-013: Share link generation and management UI component
*/
export interface ShareLink {
id: string;
share_token: string;
expires_at?: string;
access_count?: number;
created_at: string;
permissions?: string;
}
export interface ShareLinkManagerProps {
resourceId: string;
resourceType: 'track' | 'playlist';
onCreateShare: (
resourceId: string,
options: CreateShareOptions,
) => Promise<ShareLink>;
onRevokeShare?: (shareId: string) => Promise<void>;
getShareUrl: (token: string) => string;
className?: string;
}
export interface CreateShareOptions {
expires_in_days?: number;
is_public?: boolean;
permissions?: string;
}
/**
* Share link manager component for creating and managing share links
*/
export function ShareLinkManager({
resourceId,
resourceType,
onCreateShare,
onRevokeShare,
getShareUrl,
className,
}: ShareLinkManagerProps) {
const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient();
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [expiresIn, setExpiresIn] = useState<number>(7);
const [isPublic, setIsPublic] = useState(true);
const [shareLinks, setShareLinks] = useState<ShareLink[]>([]);
const [copiedToken, setCopiedToken] = useState<string | null>(null);
const [shareToRevoke, setShareToRevoke] = useState<string | null>(null);
// Create share mutation
const createShareMutation = useMutation({
mutationFn: (options: CreateShareOptions) =>
onCreateShare(resourceId, options),
onSuccess: (newShare) => {
setShareLinks((prev) => [newShare, ...prev]);
setShowCreateForm(false);
showSuccess('Lien de partage créé avec succès');
queryClient.invalidateQueries({
queryKey: [`${resourceType}ShareLinks`, resourceId],
});
},
onError: (error: any) => {
showError(error.message || 'Erreur lors de la création du lien');
},
});
// Revoke share mutation
const revokeShareMutation = useMutation({
mutationFn: (shareId: string) => {
if (onRevokeShare) {
return onRevokeShare(shareId);
}
throw new Error('Revoke function not provided');
},
onSuccess: () => {
if (shareToRevoke) {
setShareLinks((prev) => prev.filter((s) => s.id !== shareToRevoke));
setShareToRevoke(null);
showSuccess('Lien de partage révoqué');
queryClient.invalidateQueries({
queryKey: [`${resourceType}ShareLinks`, resourceId],
});
}
},
onError: (error: any) => {
showError(error.message || 'Erreur lors de la révocation');
},
});
const handleCreateShare = async () => {
setIsCreating(true);
try {
await createShareMutation.mutateAsync({
expires_in_days: expiresIn,
is_public: isPublic,
});
} finally {
setIsCreating(false);
}
};
const handleCopy = async (token: string) => {
const shareUrl = getShareUrl(token);
try {
await navigator.clipboard.writeText(shareUrl);
setCopiedToken(token);
showSuccess('Lien copié dans le presse-papiers');
setTimeout(() => setCopiedToken(null), 2000);
} catch (err) {
showError('Erreur lors de la copie');
}
};
const handleRevoke = (shareId: string) => {
setShareToRevoke(shareId);
};
const confirmRevoke = () => {
if (shareToRevoke) {
revokeShareMutation.mutate(shareToRevoke);
}
};
const isExpired = (expiresAt?: string) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5" />
Liens de partage
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateForm(!showCreateForm)}
>
<Plus className="h-4 w-4 mr-2" />
Créer un lien
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Create Form */}
{showCreateForm && (
<div className="p-4 border rounded-lg space-y-4 bg-muted/50">
<div className="space-y-2">
<Label>Expiration (jours)</Label>
<Select
value={expiresIn.toString()}
onChange={(value) => setExpiresIn(Number(value))}
options={[
{ value: '1', label: '1 jour' },
{ value: '7', label: '7 jours' },
{ value: '30', label: '30 jours' },
{ value: '90', label: '90 jours' },
{ value: '365', label: '1 an' },
{ value: '0', label: 'Jamais' },
]}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPublic"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="rounded"
/>
<Label htmlFor="isPublic" className="cursor-pointer">
Lien public (accessible sans authentification)
</Label>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreateShare}
disabled={isCreating || createShareMutation.isPending}
className="flex-1"
>
{isCreating || createShareMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Création...
</>
) : (
<>
<Share2 className="h-4 w-4 mr-2" />
Créer le lien
</>
)}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateForm(false)}
disabled={isCreating || createShareMutation.isPending}
>
Annuler
</Button>
</div>
</div>
)}
{/* Share Links List */}
{shareLinks.length === 0 && !showCreateForm ? (
<div className="text-center py-8 text-muted-foreground">
<Share2 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Aucun lien de partage créé</p>
<p className="text-sm mt-2">
Créez un lien pour partager ce{' '}
{resourceType === 'track' ? 'morceau' : 'playlist'}
</p>
</div>
) : (
<div className="space-y-3">
{shareLinks.map((share) => {
const shareUrl = getShareUrl(share.share_token);
const expired = isExpired(share.expires_at);
const isCopied = copiedToken === share.share_token;
return (
<div
key={share.id}
className={cn(
'p-4 border rounded-lg space-y-3',
expired && 'opacity-60 bg-muted/30',
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2">
<Input
value={shareUrl}
readOnly
className="flex-1 font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(share.share_token)}
>
{isCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => window.open(shareUrl, '_blank')}
title="Ouvrir le lien"
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{share.expires_at && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{expired ? (
<span className="text-destructive">
Expiré le{' '}
{format(
new Date(share.expires_at),
'dd/MM/yyyy',
{ locale: fr },
)}
</span>
) : (
<span>
Expire{' '}
{formatDistanceToNow(
new Date(share.expires_at),
{ addSuffix: true, locale: fr },
)}
</span>
)}
</div>
)}
{share.access_count !== undefined && (
<div className="flex items-center gap-1">
<Eye className="h-3 w-3" />
<span>{share.access_count} accès</span>
</div>
)}
<div className="text-xs">
Créé{' '}
{formatDistanceToNow(new Date(share.created_at), {
addSuffix: true,
locale: fr,
})}
</div>
</div>
</div>
{onRevokeShare && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRevoke(share.id)}
className="text-destructive hover:text-destructive"
title="Révoquer le lien"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
{/* Revoke Confirmation Dialog */}
{onRevokeShare && (
<ConfirmationDialog
open={!!shareToRevoke}
onClose={() => setShareToRevoke(null)}
onConfirm={confirmRevoke}
title="Révoquer le lien de partage"
description="Êtes-vous sûr de vouloir révoquer ce lien ? Il ne sera plus accessible."
confirmLabel="Révoquer"
cancelLabel="Annuler"
variant="destructive"
/>
)}
</Card>
);
}