[dyad] Added an account page to manage clips - wrote 3 file(s), executed 1 SQL queries
This commit is contained in:
82
src/app/account/page.tsx
Normal file
82
src/app/account/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { Loader2, Video } from 'lucide-react';
|
||||||
|
import { UserClips } from '@/components/user-clips';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export type Clip = {
|
||||||
|
id: string;
|
||||||
|
short_id: string;
|
||||||
|
title: string;
|
||||||
|
storage_path: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [clips, setClips] = useState<Clip[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserAndClips = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('clips')
|
||||||
|
.select('id, short_id, title, storage_path, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching clips:', error);
|
||||||
|
} else {
|
||||||
|
setClips(data as Clip[]);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUserAndClips();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const onClipDeleted = (clipId: string) => {
|
||||||
|
setClips(prevClips => prevClips.filter(clip => clip.id !== clipId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClipUpdated = (updatedClip: Clip) => {
|
||||||
|
setClips(prevClips => prevClips.map(clip => clip.id === updatedClip.id ? updatedClip : clip));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-5xl py-24 px-4">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight mb-8">My Clips</h1>
|
||||||
|
{clips.length > 0 ? (
|
||||||
|
<UserClips clips={clips} onClipDeleted={onClipDeleted} onClipUpdated={onClipUpdated} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6 border-2 border-dashed rounded-lg">
|
||||||
|
<Video className="w-16 h-16 mb-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-2xl font-semibold mb-2">No clips yet</h2>
|
||||||
|
<p className="text-muted-foreground mb-6">You haven't created any clips. Go ahead and make your first one!</p>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Create a Clip</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,22 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { User } from '@supabase/supabase-js';
|
import { User as SupabaseUser } from '@supabase/supabase-js';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut, User } from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<SupabaseUser | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,12 +43,34 @@ export function Header() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="absolute top-0 right-0 p-4">
|
<header className="absolute top-0 right-0 p-4">
|
||||||
<div className="flex items-center space-x-4">
|
<DropdownMenu>
|
||||||
<span className="text-sm text-muted-foreground">{user.email}</span>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" onClick={handleSignOut}>
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
<LogOut className="w-5 h-5" />
|
<User className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">My Account</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground truncate">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/account">
|
||||||
|
My Clips
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleSignOut}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>Log out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
192
src/components/user-clips.tsx
Normal file
192
src/components/user-clips.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"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';
|
||||||
|
|
||||||
|
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 { error: storageError } = await supabase.storage.from('clips').remove([clip.storage_path]);
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 flex items-center justify-center text-muted-foreground">
|
||||||
|
Click to view
|
||||||
|
</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 remove its data 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user