Skip to content

Commit 45f95d0

Browse files
authored
Merge pull request #233 from OpenPecha/tasklevel-youtube
add youtube videos
2 parents 72adbfd + ac0e9db commit 45f95d0

3 files changed

Lines changed: 333 additions & 0 deletions

File tree

src/components/routes/task/api/planApi.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,54 @@ export const deleteDayAudio = async (day_id: string) => {
200200
headers: getAuthHeaders(),
201201
});
202202
};
203+
204+
export interface DayVideo {
205+
id: string;
206+
day_id: string;
207+
url: string;
208+
video_id: string | null;
209+
title: string | null;
210+
display_order: number;
211+
created_at: string;
212+
}
213+
214+
export type DayVideoSummary = Pick<
215+
DayVideo,
216+
"id" | "url" | "video_id" | "title" | "display_order"
217+
>;
218+
219+
export interface AddDayVideoPayload {
220+
url: string;
221+
title?: string | null;
222+
}
223+
224+
export const addDayVideo = async (
225+
day_id: string,
226+
payload: AddDayVideoPayload,
227+
) => {
228+
const { data } = await axiosInstance.post<DayVideo>(
229+
`/api/v1/cms/plans/days/${day_id}/videos`,
230+
payload,
231+
{ headers: getAuthHeaders() },
232+
);
233+
return data;
234+
};
235+
236+
export const deleteDayVideo = async (day_id: string, video_id: string) => {
237+
await axiosInstance.delete(
238+
`/api/v1/cms/plans/days/${day_id}/videos/${video_id}`,
239+
{ headers: getAuthHeaders() },
240+
);
241+
};
242+
243+
export const reorderDayVideos = async (
244+
day_id: string,
245+
videos: Array<{ id: string; display_order: number }>,
246+
) => {
247+
const { data } = await axiosInstance.put<{ videos: DayVideo[] }>(
248+
`/api/v1/cms/plans/days/${day_id}/videos/order`,
249+
{ videos },
250+
{ headers: getAuthHeaders() },
251+
);
252+
return data.videos;
253+
};

src/components/routes/task/components/sidebar-component/SideBar.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Pecha } from "@/components/ui/shadimport";
77
import TaskDeleteDialog from "@/components/ui/molecules/modals/task-delete/TaskDeleteDialog";
88
import DayDeleteDialog from "@/components/ui/molecules/modals/day-delete/DayDeleteDialog";
99
import DayAudioDialog from "@/components/ui/molecules/modals/day-audio/DayAudioDialog";
10+
import DayVideosDialog from "@/components/ui/molecules/modals/day-videos/DayVideosDialog";
1011
import DayCreateDialog from "@/components/ui/molecules/modals/day-create/DayCreateDialog";
1112
import { useParams } from "react-router-dom";
1213
import { SortableList, SortableItem } from "@/components/ui/atoms/sortable";
@@ -293,6 +294,18 @@ const SideBar = ({
293294
language={currentPlan?.language}
294295
/>
295296
</Pecha.DropdownMenuItem>
297+
<Pecha.DropdownMenuItem
298+
className="gap-2 cursor-pointer"
299+
onSelect={(e) => e.preventDefault()}
300+
>
301+
<DayVideosDialog
302+
planId={planId!}
303+
dayId={day.id}
304+
dayNumber={day.day_number}
305+
videos={day.videos}
306+
isEditable={isEditable}
307+
/>
308+
</Pecha.DropdownMenuItem>
296309
<Pecha.DropdownMenuItem className="gap-2 cursor-pointer">
297310
<DayDeleteDialog
298311
onDelete={() => handleDeleteDay(day.id)}
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { useEffect, useState } from "react";
2+
import { useMutation, useQueryClient } from "@tanstack/react-query";
3+
import { toast } from "sonner";
4+
import { Pecha } from "@/components/ui/shadimport";
5+
import { SortableList, SortableItem } from "@/components/ui/atoms/sortable";
6+
import { FaYoutube } from "react-icons/fa";
7+
import { FiLoader, FiPlus } from "react-icons/fi";
8+
import { FaTrash } from "react-icons/fa6";
9+
import { PiDotsSixVertical } from "react-icons/pi";
10+
import {
11+
addDayVideo,
12+
deleteDayVideo,
13+
reorderDayVideos,
14+
type DayVideoSummary,
15+
} from "@/components/routes/task/api/planApi";
16+
import { getApiErrorMessage } from "@/lib/apiErrors";
17+
import {
18+
getYouTubeVideoId,
19+
getYouTubeShortsId,
20+
reorderArray,
21+
} from "@/lib/utils";
22+
23+
interface DayVideosDialogProps {
24+
planId: string;
25+
dayId: string;
26+
dayNumber: number;
27+
videos?: DayVideoSummary[];
28+
isEditable?: boolean;
29+
}
30+
31+
const isYouTubeUrl = (url: string) =>
32+
Boolean(getYouTubeVideoId(url) || getYouTubeShortsId(url));
33+
34+
const sortByOrder = (videos: DayVideoSummary[]) =>
35+
[...videos].sort((a, b) => a.display_order - b.display_order);
36+
37+
const DayVideosDialog = ({
38+
planId,
39+
dayId,
40+
dayNumber,
41+
videos = [],
42+
isEditable,
43+
}: DayVideosDialogProps) => {
44+
const [open, setOpen] = useState(false);
45+
const [url, setUrl] = useState("");
46+
// Local copy so drag-reorder updates instantly before the server confirms.
47+
const [orderedVideos, setOrderedVideos] = useState<DayVideoSummary[]>(
48+
sortByOrder(videos),
49+
);
50+
const queryClient = useQueryClient();
51+
52+
useEffect(() => {
53+
setOrderedVideos(sortByOrder(videos));
54+
}, [videos]);
55+
56+
const invalidatePlan = () =>
57+
queryClient.invalidateQueries({ queryKey: ["planDetails", planId] });
58+
59+
const addMutation = useMutation({
60+
mutationFn: (videoUrl: string) => addDayVideo(dayId, { url: videoUrl }),
61+
onSuccess: () => {
62+
setUrl("");
63+
toast.success("Video added");
64+
invalidatePlan();
65+
},
66+
onError: (error: unknown) => {
67+
toast.error("Failed to add video", {
68+
description: getApiErrorMessage(error),
69+
});
70+
},
71+
});
72+
73+
const deleteMutation = useMutation({
74+
mutationFn: (videoId: string) => deleteDayVideo(dayId, videoId),
75+
onSuccess: () => {
76+
toast.success("Video removed");
77+
invalidatePlan();
78+
},
79+
onError: (error: unknown) => {
80+
toast.error("Failed to remove video", {
81+
description: getApiErrorMessage(error),
82+
});
83+
},
84+
});
85+
86+
const reorderMutation = useMutation({
87+
mutationFn: (payload: Array<{ id: string; display_order: number }>) =>
88+
reorderDayVideos(dayId, payload),
89+
onSuccess: () => {
90+
invalidatePlan();
91+
},
92+
onError: (error: unknown) => {
93+
setOrderedVideos(sortByOrder(videos)); // revert optimistic order
94+
toast.error("Failed to reorder videos", {
95+
description: getApiErrorMessage(error),
96+
});
97+
},
98+
});
99+
100+
const trimmedUrl = url.trim();
101+
const urlIsValid = isYouTubeUrl(trimmedUrl);
102+
const isBusy =
103+
addMutation.isPending ||
104+
deleteMutation.isPending ||
105+
reorderMutation.isPending;
106+
107+
const handleAdd = () => {
108+
if (!trimmedUrl || !urlIsValid) return;
109+
addMutation.mutate(trimmedUrl);
110+
};
111+
112+
const handleReorder = (activeId: string, overId: string) => {
113+
if (activeId === overId) return;
114+
const next = reorderArray(orderedVideos, activeId, overId);
115+
if (!next) return;
116+
setOrderedVideos(next);
117+
reorderMutation.mutate(
118+
next.map((video, index) => ({ id: video.id, display_order: index })),
119+
);
120+
};
121+
122+
return (
123+
<>
124+
<span
125+
onClick={(e) => {
126+
e.stopPropagation();
127+
setOpen(true);
128+
}}
129+
className="flex items-center gap-2 cursor-pointer w-full"
130+
>
131+
<FaYoutube className="w-4 h-4" /> YouTube videos
132+
{orderedVideos.length > 0 && (
133+
<span className="ml-auto text-xs text-muted-foreground">
134+
{orderedVideos.length}
135+
</span>
136+
)}
137+
</span>
138+
139+
<Pecha.Dialog open={open} onOpenChange={setOpen}>
140+
<Pecha.DialogContent className="sm:max-w-lg">
141+
<Pecha.DialogHeader>
142+
<Pecha.DialogTitle>Day {dayNumber} videos</Pecha.DialogTitle>
143+
</Pecha.DialogHeader>
144+
145+
{isEditable && (
146+
<div className="space-y-1">
147+
<div className="flex gap-2">
148+
<Pecha.Input
149+
type="url"
150+
placeholder="Enter YouTube URL"
151+
value={url}
152+
disabled={isBusy}
153+
onChange={(e) => setUrl(e.target.value)}
154+
onKeyDown={(e) => {
155+
if (e.key === "Enter") {
156+
e.preventDefault();
157+
handleAdd();
158+
}
159+
}}
160+
/>
161+
<Pecha.Button
162+
type="button"
163+
className="bg-[#A51C21] hover:bg-[#A51C21]/90 shrink-0"
164+
disabled={isBusy || !trimmedUrl || !urlIsValid}
165+
onClick={handleAdd}
166+
>
167+
{addMutation.isPending ? (
168+
<FiLoader className="w-4 h-4 animate-spin" />
169+
) : (
170+
<FiPlus className="w-4 h-4" />
171+
)}
172+
Add
173+
</Pecha.Button>
174+
</div>
175+
{trimmedUrl && !urlIsValid && (
176+
<p className="text-xs text-[#A51C21]">
177+
Enter a valid YouTube URL.
178+
</p>
179+
)}
180+
</div>
181+
)}
182+
183+
<div className="max-h-[50vh] overflow-y-auto">
184+
{orderedVideos.length === 0 ? (
185+
<p className="text-sm text-muted-foreground py-4 text-center">
186+
No videos added yet.
187+
</p>
188+
) : (
189+
<SortableList
190+
items={orderedVideos.map((video) => ({ id: video.id }))}
191+
onReorder={handleReorder}
192+
disabled={!isEditable || isBusy}
193+
>
194+
<div className="space-y-3">
195+
{orderedVideos.map((video) => {
196+
const videoId =
197+
video.video_id ||
198+
getYouTubeVideoId(video.url) ||
199+
getYouTubeShortsId(video.url);
200+
return (
201+
<SortableItem
202+
key={video.id}
203+
id={video.id}
204+
disabled={!isEditable || isBusy}
205+
className="flex items-center gap-3 rounded-lg border border-dashed border-gray-300 dark:border-input p-2 bg-white dark:bg-[#161616]"
206+
>
207+
{({ listeners }: any) => (
208+
<>
209+
{isEditable && (
210+
<PiDotsSixVertical
211+
className="w-4 h-4 text-gray-400 dark:text-muted-foreground cursor-grab active:cursor-grabbing shrink-0"
212+
{...listeners}
213+
/>
214+
)}
215+
{videoId && (
216+
<img
217+
src={`https://img.youtube.com/vi/${videoId}/default.jpg`}
218+
alt={video.title ?? "YouTube video"}
219+
className="w-20 h-14 object-cover rounded shrink-0"
220+
/>
221+
)}
222+
<div className="min-w-0 flex-1">
223+
<a
224+
href={video.url}
225+
target="_blank"
226+
rel="noreferrer"
227+
className="text-sm text-foreground hover:underline block truncate"
228+
title={video.title ?? video.url}
229+
>
230+
{video.title || video.url}
231+
</a>
232+
<span className="text-xs text-muted-foreground truncate block">
233+
{video.url}
234+
</span>
235+
</div>
236+
{isEditable && (
237+
<Pecha.Button
238+
type="button"
239+
variant="outline"
240+
size="icon"
241+
className="shrink-0 h-9 w-9"
242+
disabled={isBusy}
243+
title="Remove video"
244+
onClick={() => deleteMutation.mutate(video.id)}
245+
>
246+
{deleteMutation.isPending &&
247+
deleteMutation.variables === video.id ? (
248+
<FiLoader className="w-4 h-4 animate-spin" />
249+
) : (
250+
<FaTrash className="w-4 h-4" />
251+
)}
252+
</Pecha.Button>
253+
)}
254+
</>
255+
)}
256+
</SortableItem>
257+
);
258+
})}
259+
</div>
260+
</SortableList>
261+
)}
262+
</div>
263+
</Pecha.DialogContent>
264+
</Pecha.Dialog>
265+
</>
266+
);
267+
};
268+
269+
export default DayVideosDialog;

0 commit comments

Comments
 (0)