214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from 'react';
|
|
import { Clip } from '@/app/account/page';
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogClose } from '@/components/ui/dialog';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { toast } from 'sonner';
|
|
import { Toaster } from '@/components/ui/sonner';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { MoreVertical, Edit, Trash2, Loader2, Copy, Check } from 'lucide-react';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
|
|
type UserClipsProps = {
|
|
clips: Clip[];
|
|
onClipDeleted: (clipId: string) => void;
|
|
onClipUpdated: (clip: Clip) => void;
|
|
};
|
|
|
|
export function UserClips({ clips, onClipDeleted, onClipUpdated }: UserClipsProps) {
|
|
const [clipToEdit, setClipToEdit] = useState<Clip | null>(null);
|
|
const [newTitle, setNewTitle] = useState('');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const [hasCopied, setHasCopied] = useState<string | null>(null);
|
|
|
|
const handleEditClick = (clip: Clip) => {
|
|
setClipToEdit(clip);
|
|
setNewTitle(clip.title);
|
|
};
|
|
|
|
const handleSaveChanges = async () => {
|
|
if (!clipToEdit || !newTitle.trim()) return;
|
|
setIsSaving(true);
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('clips')
|
|
.update({ title: newTitle.trim() })
|
|
.eq('id', clipToEdit.id)
|
|
.select()
|
|
.single();
|
|
|
|
if (error) throw error;
|
|
|
|
onClipUpdated(data as Clip);
|
|
toast.success('Title updated successfully!');
|
|
setClipToEdit(null);
|
|
} catch (error) {
|
|
toast.error('Failed to update title.');
|
|
console.error(error);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteClip = async (clip: Clip) => {
|
|
setIsDeleting(true);
|
|
try {
|
|
const pathsToRemove = [clip.storage_path];
|
|
if (clip.thumbnail_storage_path) {
|
|
pathsToRemove.push(clip.thumbnail_storage_path);
|
|
}
|
|
const { error: storageError } = await supabase.storage.from('clips').remove(pathsToRemove);
|
|
if (storageError) throw storageError;
|
|
|
|
const { error: dbError } = await supabase.from('clips').delete().eq('id', clip.id);
|
|
if (dbError) throw dbError;
|
|
|
|
onClipDeleted(clip.id);
|
|
toast.success('Clip deleted successfully!');
|
|
} catch (error) {
|
|
toast.error('Failed to delete clip.');
|
|
console.error(error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (shortId: string) => {
|
|
const link = `${window.location.origin}/clips/${shortId}`;
|
|
navigator.clipboard.writeText(link);
|
|
setHasCopied(shortId);
|
|
setTimeout(() => setHasCopied(null), 2000);
|
|
};
|
|
|
|
const getThumbnailUrl = (path: string) => {
|
|
const { data } = supabase.storage.from('clips').getPublicUrl(path);
|
|
return data.publicUrl;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Toaster />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{clips.map((clip) => (
|
|
<Card key={clip.id} className="flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle className="truncate flex justify-between items-start">
|
|
<Link href={`/clips/${clip.short_id}`} className="hover:underline flex-1 pr-2">
|
|
{clip.title || 'Untitled Clip'}
|
|
</Link>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="flex-shrink-0">
|
|
<MoreVertical className="h-5 w-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DialogTrigger asChild onClick={() => handleEditClick(clip)}>
|
|
<DropdownMenuItem>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
<span>Edit Title</span>
|
|
</DropdownMenuItem>
|
|
</DialogTrigger>
|
|
<AlertDialogTrigger asChild>
|
|
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
<span>Delete</span>
|
|
</DropdownMenuItem>
|
|
</AlertDialogTrigger>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex-grow">
|
|
<Link href={`/clips/${clip.short_id}`}>
|
|
<div className="aspect-video w-full bg-muted rounded-md overflow-hidden relative">
|
|
{clip.thumbnail_storage_path ? (
|
|
<Image
|
|
src={getThumbnailUrl(clip.thumbnail_storage_path)}
|
|
alt={clip.title || 'Clip thumbnail'}
|
|
layout="fill"
|
|
objectFit="cover"
|
|
className="transition-transform duration-300 hover:scale-105"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
|
No thumbnail
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
</CardContent>
|
|
<CardFooter className="flex justify-between items-center text-sm text-muted-foreground">
|
|
<span>{formatDistanceToNow(new Date(clip.created_at), { addSuffix: true })}</span>
|
|
<Button variant="outline" size="sm" onClick={() => copyToClipboard(clip.short_id)}>
|
|
{hasCopied === clip.short_id ? <Check className="mr-2 h-4 w-4 text-green-500" /> : <Copy className="mr-2 h-4 w-4" />}
|
|
{hasCopied === clip.short_id ? 'Copied!' : 'Copy Link'}
|
|
</Button>
|
|
</CardFooter>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This action cannot be undone. This will permanently delete your clip and its thumbnail from our servers.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={() => handleDeleteClip(clip)} disabled={isDeleting} className="bg-destructive hover:bg-destructive/90">
|
|
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Edit Title Dialog */}
|
|
<Dialog open={!!clipToEdit} onOpenChange={(isOpen) => !isOpen && setClipToEdit(null)}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Clip Title</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label htmlFor="title" className="text-right">
|
|
Title
|
|
</Label>
|
|
<Input
|
|
id="title"
|
|
value={newTitle}
|
|
onChange={(e) => setNewTitle(e.target.value)}
|
|
className="col-span-3"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogClose asChild>
|
|
<Button type="button" variant="secondary" disabled={isSaving}>
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<Button type="submit" onClick={handleSaveChanges} disabled={isSaving}>
|
|
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Save changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
} |