diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 6ba423c..a635ad4 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -1,45 +1,57 @@ import clsx from "clsx"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { Link } from "react-router-dom"; -import {useEffect, useState} from "react"; +import { useEffect, useRef, useState } from "react"; import { isThumbnailAvailable } from "../../utils/endpoints.ts"; +import { MoreVertical, Trash2 } from "lucide-react"; type VideoCardProps = { id: number, title: string, duration: number, createdAt: string, - className?: string + onDelete?: () => void, + className?: string, } const fallbackThumbnail = "../../../public/default_thumbnail.png"; const API_URL = import.meta.env.VITE_API_URL; -const VideoCard = ({ - id, - title, - duration, - createdAt, - className - }: VideoCardProps) => { - +const VideoCard = ({ id, title, duration, createdAt, onDelete, className }: VideoCardProps) => { const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [thumbnailAvailable, setThumbnailAvailable] = useState(true); + const [menuOpen, setMenuOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const menuRef = useRef(null); setTimeout(() => { - setTimeAgo(dateToTimeAgo(stringToDate(createdAt))) + setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); }, 1000); useEffect(() => { isThumbnailAvailable(id) - .then((available) => { - setThumbnailAvailable(available); - }) - .catch(() => { - setThumbnailAvailable(false); - }); + .then(setThumbnailAvailable) + .catch(() => setThumbnailAvailable(false)); }, []); + useEffect(() => { + if (!menuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setMenuOpen(false); + setConfirmDelete(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [menuOpen]); + + const stopAndRun = (e: React.MouseEvent, fn: () => void) => { + e.preventDefault(); + e.stopPropagation(); + fn(); + }; + return (
@@ -49,10 +61,59 @@ const VideoCard = ({ alt="Video Thumbnail" className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" /> + {formatTime(duration)} + + {onDelete && ( +
e.preventDefault()} + > + + + {menuOpen && ( +
+ {!confirmDelete && ( + + )} + {confirmDelete && ( +
+

Delete this clip?

+
+ + +
+
+ )} +
+ )} +
+ )}
+

{title === "" ? "(No Title)" : title} diff --git a/frontend/src/pages/MyClips.tsx b/frontend/src/pages/MyClips.tsx index 867d55d..ebe3224 100644 --- a/frontend/src/pages/MyClips.tsx +++ b/frontend/src/pages/MyClips.tsx @@ -1,6 +1,6 @@ import VideoCard from "../components/video/VideoCard"; -import {useEffect, useState} from "react"; -import { getClips } from "../utils/endpoints"; +import { useEffect, useState } from "react"; +import { getClips, deleteClip } from "../utils/endpoints"; import type { Clip } from "../utils/types"; const MyClips = () => { @@ -9,10 +9,19 @@ const MyClips = () => { useEffect(() => { getClips() - .then((data) => setClips(data)) + .then(setClips) .catch((err) => setError(err)); }, []); + const handleDelete = async (id: number) => { + try { + await deleteClip(id); + setClips(prev => prev.filter(c => c.id !== id)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete clip"); + } + }; + return (

My Clips

@@ -25,6 +34,7 @@ const MyClips = () => { title={clip.title} duration={clip.duration} createdAt={clip.createdAt} + onDelete={() => handleDelete(clip.id)} /> ))}
diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts index 38bda9f..e944516 100644 --- a/frontend/src/utils/endpoints.ts +++ b/frontend/src/utils/endpoints.ts @@ -253,13 +253,27 @@ const getVideoBlob = async(id: string): Promise => { } } -const isThumbnailAvailable = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); +const deleteClip = async (id: number): Promise => { + const response = await fetch(API_URL + `/api/v1/clips/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!response.ok) { - return false; + throw new Error(`Failed to delete clip: ${response.status}`); } - return true; + const result: APIResponse = await response.json(); + if (result.status === 'error') { + throw new Error(`Failed to delete clip: ${result.message}`); + } +}; + +const isThumbnailAvailable = async (id: number): Promise => { + const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); + return response.ok; + + } export { @@ -275,5 +289,6 @@ export { getClips, getClipById, getVideoBlob, + deleteClip, isThumbnailAvailable }; \ No newline at end of file