360 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|