feat(frontend): add delete option for clips
This commit is contained in:
@@ -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<HTMLDivElement>(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 (
|
||||
<Link to={"/video/" + id}>
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
@@ -49,10 +61,59 @@ const VideoCard = ({
|
||||
alt="Video Thumbnail"
|
||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
<span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
|
||||
{formatTime(duration)}
|
||||
</span>
|
||||
|
||||
{onDelete && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
|
||||
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
|
||||
{!confirmDelete && (
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={13} /> Delete
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, onDelete!)}
|
||||
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
|
||||
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col p-2">
|
||||
<p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
|
||||
{title === "" ? "(No Title)" : title}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getClips } from "../utils/endpoints";
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
|
||||
@@ -25,6 +34,7 @@ const MyClips = () => {
|
||||
title={clip.title}
|
||||
duration={clip.duration}
|
||||
createdAt={clip.createdAt}
|
||||
onDelete={() => handleDelete(clip.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -253,13 +253,27 @@ const getVideoBlob = async(id: string): Promise<Blob> => {
|
||||
}
|
||||
}
|
||||
|
||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
|
||||
const deleteClip = async (id: number): Promise<void> => {
|
||||
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<boolean> => {
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user