Skip to content

Commit cff38c1

Browse files
committed
youtube video fallback
added in a fallback set of inputs if for some reason there is an error when attempting to load the available videos from youtube for the channel. also added in the `ReactQueryDevtools` to the app to help with inspecting data loaded by the react-query client.
1 parent 6edbafc commit cff38c1

6 files changed

Lines changed: 192 additions & 85 deletions

File tree

codewit/client/src/components/form/VideoSelect.tsx

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,69 +5,18 @@ import axios from 'axios';
55
import { SelectStyles } from '../../utils/styles.js';
66
import { useQuery } from '@tanstack/react-query';
77
import { Label } from 'flowbite-react';
8+
import { VideoOption, use_yt_videos } from "../../hooks/yt_videos";
89

910
interface VideoSelectProps {
1011
youtube_id: string,
1112
required?: boolean,
1213
onSelectVideo: (videoId: string, videoThumbnail: string) => void,
1314
}
1415

15-
interface VideoOption {
16-
value: string,
17-
label: string,
18-
thumbnail: string,
19-
}
20-
2116
const VideoSelect = ({ youtube_id, required = false, onSelectVideo }: VideoSelectProps): JSX.Element => {
2217
const [selected_option, set_selected_option] = useState<VideoOption | null>(null);
2318

24-
const {data: videos, isFetching, error} = useQuery({
25-
queryKey: ["youtube_videos"],
26-
queryFn: async (): Promise<VideoOption[]> => {
27-
const apiKey = import.meta.env.VITE_KEY;
28-
const channelId = import.meta.env.VITE_CHANNEL_ID;
29-
30-
let rtn = [];
31-
let next_page_token: string | null = null;
32-
33-
while (true) {
34-
// results are paginated so if we are to show all of the videos in the
35-
// select then we will need to retrieve all of the videos.
36-
let url = `https://www.googleapis.com/youtube/v3/search?key=${apiKey}&channelId=${channelId}&part=snippet,id&order=date&type=video&maxResults=50`;
37-
38-
if (next_page_token != null) {
39-
url += `&pageToken=${next_page_token}`;
40-
}
41-
42-
const response = await axios.get(url);
43-
44-
if (response.data.error) {
45-
throw new Error(response.data.error.message);
46-
}
47-
48-
for (let item of response.data.items) {
49-
rtn.push({
50-
value: item.id.videoId,
51-
label: item.snippet.title,
52-
thumbnail: item.snippet.thumbnails.high.url,
53-
});
54-
}
55-
56-
if (response.data.nextPageToken != null) {
57-
next_page_token = response.data.nextPageToken;
58-
} else {
59-
break;
60-
}
61-
}
62-
63-
return rtn;
64-
},
65-
// reduce the amount of times we will make the requests by caching the
66-
// results.
67-
staleTime: 5* 60 * 1000, // 5 minutes
68-
gcTime: 5 * 60 * 1000, // 5 minutes
69-
retry: false,
70-
});
19+
const {data: videos, isFetching, error} = use_yt_videos();
7120

7221
useEffect(() => {
7322
if (youtube_id && videos != null) {
@@ -110,4 +59,4 @@ const VideoSelect = ({ youtube_id, required = false, onSelectVideo }: VideoSelec
11059
);
11160
};
11261

113-
export default VideoSelect;
62+
export default VideoSelect;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import axios from "axios";
2+
import { useQuery } from "@tanstack/react-query";
3+
4+
export interface VideoOption {
5+
value: string,
6+
label: string,
7+
thumbnail: string,
8+
}
9+
10+
export function yt_video_key(): ["youtube_videos"] {
11+
return ["youtube_videos"]
12+
}
13+
14+
export function use_yt_videos() {
15+
return useQuery({
16+
queryKey: yt_video_key(),
17+
queryFn: async (): Promise<VideoOption[]> => {
18+
const apiKey = import.meta.env.VITE_KEY;
19+
const channelId = import.meta.env.VITE_CHANNEL_ID;
20+
21+
let rtn = [];
22+
let next_page_token: string | null = null;
23+
24+
while (true) {
25+
// results are paginated so if we are to show all of the videos in the
26+
// select then we will need to retrieve all of the videos.
27+
let url = `https://www.googleapis.com/youtube/v3/search?key=${apiKey}&channelId=${channelId}&part=snippet,id&order=date&type=video&maxResults=50`;
28+
29+
if (next_page_token != null) {
30+
url += `&pageToken=${next_page_token}`;
31+
}
32+
33+
const response = await axios.get(url);
34+
35+
if (response.data.error) {
36+
throw new Error(response.data.error.message);
37+
}
38+
39+
for (let item of response.data.items) {
40+
rtn.push({
41+
value: item.id.videoId,
42+
label: item.snippet.title,
43+
thumbnail: item.snippet.thumbnails.high.url,
44+
});
45+
}
46+
47+
if (response.data.nextPageToken != null) {
48+
next_page_token = response.data.nextPageToken;
49+
} else {
50+
break;
51+
}
52+
}
53+
54+
return rtn;
55+
},
56+
// reduce the amount of times we will make the requests by caching the
57+
// results.
58+
staleTime: 5* 60 * 1000, // 5 minutes
59+
gcTime: 5 * 60 * 1000, // 5 minutes
60+
retry: false,
61+
});
62+
}

codewit/client/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as ReactDOM from 'react-dom/client';
44
import { BrowserRouter } from 'react-router-dom';
55
import { ToastContainer } from "react-toastify";
66
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
7+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
78
import "react-toastify/dist/ReactToastify.css";
89

910
import App from './app/app';
@@ -21,6 +22,7 @@ root.render(
2122
<App />
2223
<ToastContainer position="bottom-right" autoClose={1000} />
2324
</BrowserRouter>
25+
<ReactQueryDevtools initialIsOpen={false}/>
2426
</QueryClientProvider>
2527
// </StrictMode>
2628
);

codewit/client/src/pages/DemoForm.tsx

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,13 @@
11
// codewit/client/src/pages/DemoForm.tsx
22
import { useEffect, useMemo, useState } from "react";
3-
import ReusableTable, { Column } from "../components/form/ReusableTable";
43
import { toast } from "react-toastify";
5-
import VideoSelect from "../components/form/VideoSelect";
6-
import { topic_options } from "../components/form/TagSelect";
7-
import { language_options, get_language_option } from "../components/form/LanguageSelect";
8-
import CreateButton from "../components/form/CreateButton";
94
import { DemoResponse, ExerciseResponse } from "@codewit/interfaces";
10-
import { isFormValid } from "../utils/formValidationUtils";
11-
import LoadingPage from "../components/loading/LoadingPage";
12-
import { Modal, ModalBody, ModalFooter, ModalHeader } from "../components/ui/modal";
135
import { useField, useForm } from "@tanstack/react-form";
14-
import { Button, Label, TextInput } from "flowbite-react";
6+
import { Button, Label, TextInput, HelperText } from "flowbite-react";
157
import CreatableSelect from "react-select/creatable";
16-
import { cn, SelectStyles } from "../utils/styles";
178
import Select from "react-select";
189
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1910
import axios from "axios";
20-
import { ErrorView } from "../components/error/Error";
2111
import {
2212
DndContext,
2313
closestCenter,
@@ -36,6 +26,18 @@ import {
3626
} from "@dnd-kit/sortable";
3727
import { Bars3Icon, TrashIcon } from "@heroicons/react/24/solid";
3828

29+
import { cn, SelectStyles } from "../utils/styles";
30+
import { ErrorView } from "../components/error/Error";
31+
import { isFormValid } from "../utils/formValidationUtils";
32+
import LoadingPage from "../components/loading/LoadingPage";
33+
import { Modal, ModalBody, ModalFooter, ModalHeader } from "../components/ui/modal";
34+
import VideoSelect from "../components/form/VideoSelect";
35+
import { topic_options } from "../components/form/TagSelect";
36+
import { language_options, get_language_option } from "../components/form/LanguageSelect";
37+
import CreateButton from "../components/form/CreateButton";
38+
import ReusableTable, { Column } from "../components/form/ReusableTable";
39+
import { VideoOption, use_yt_videos } from "../hooks/yt_videos";
40+
3941
interface DemoForm {
4042
uid?: number,
4143
title: string,
@@ -171,6 +173,10 @@ interface DemoFormProps {
171173
}
172174

173175
function DemoForm({view, demo, on_cancel, on_created, on_updated}: DemoFormProps) {
176+
const {
177+
error: yt_video_error
178+
} = use_yt_videos();
179+
174180
const send_demo = useMutation({
175181
mutationFn: async (data: DemoForm) => {
176182
let body = {
@@ -216,7 +222,8 @@ function DemoForm({view, demo, on_cancel, on_created, on_updated}: DemoFormProps
216222
let result = await send_demo.mutateAsync(value);
217223

218224
form.reset(demo_form(result.data));
219-
}
225+
},
226+
validators: {}
220227
});
221228

222229
useEffect(() => {
@@ -227,9 +234,7 @@ function DemoForm({view, demo, on_cancel, on_created, on_updated}: DemoFormProps
227234
form.reset();
228235
}, [view]);
229236

230-
return <Modal show={view} position="center" size="2xl" onClose={() => {
231-
on_cancel();
232-
}}>
237+
return <Modal show={view} position="center" size="2xl" onClose={() => on_cancel()}>
233238
<ModalHeader>
234239
<form.Subscribe
235240
selector={state => ([state.values.uid])}
@@ -257,13 +262,73 @@ function DemoForm({view, demo, on_cancel, on_created, on_updated}: DemoFormProps
257262
/>
258263
</div>;
259264
}}/>
260-
<form.Field name="youtube_id" children={(field) => {
261-
return <VideoSelect required youtube_id={field.state.value} onSelectVideo={(id, thumbnail) => {
262-
field.handleChange(id);
263-
// have to use this since the id and thunbnail are tied to the same input
264-
form.setFieldValue("youtube_thumbnail", thumbnail);
265-
}}/>
266-
}}/>
265+
{yt_video_error ?
266+
<>
267+
<form.Field name="youtube_id" children={(field) => {
268+
return <div className="space-y-2">
269+
<Label htmlFor={field.name}>
270+
Youtube ID
271+
</Label>
272+
<TextInput
273+
id={field.name}
274+
name={field.name}
275+
value={field.state.value}
276+
onBlur={field.handleBlur}
277+
onChange={ev => field.handleChange(ev.target.value)}
278+
/>
279+
<HelperText>
280+
Only the Youtube ID which is located in the URL of a video on the site.
281+
</HelperText>
282+
</div>
283+
}}/>
284+
<form.Field
285+
name="youtube_thumbnail"
286+
validators={{
287+
onBlur: ({value}) => {
288+
try {
289+
let check = new URL(value);
290+
291+
return undefined;
292+
} catch(err) {
293+
return "The thumbnail URL must be a valid absolute URL";
294+
}
295+
}
296+
}}
297+
children={(field) => {
298+
let invalid_color = !field.state.meta.isValid ? "failure" : undefined;
299+
300+
return <div className="space-y-2">
301+
<Label htmlFor={field.name} color={invalid_color}>
302+
Youtube Thumbnail
303+
</Label>
304+
<TextInput
305+
id={field.name}
306+
name={field.name}
307+
value={field.state.value}
308+
onBlur={field.handleBlur}
309+
onChange={ev => field.handleChange(ev.target.value)}
310+
color={invalid_color}
311+
/>
312+
<HelperText>
313+
{field.state.meta.errors.length !== 0 ?
314+
field.state.meta.errors.join(", ") + ". "
315+
:
316+
"The full url of the video thumbnail."
317+
}
318+
</HelperText>
319+
</div>;
320+
}}
321+
/>
322+
</>
323+
:
324+
<form.Field name="youtube_id" children={(field) => {
325+
return <VideoSelect required youtube_id={field.state.value} onSelectVideo={(id, thumbnail) => {
326+
field.handleChange(id);
327+
// have to use this since the id and thunbnail are tied to the same input
328+
form.setFieldValue("youtube_thumbnail", thumbnail);
329+
}}/>
330+
}}/>
331+
}
267332
<form.Field name="topic" children={field => {
268333
return <div className="space-y-2">
269334
<Label htmlFor={field.name}>
@@ -482,4 +547,4 @@ function SortableExerciseItem({uid, prompt, on_remove}: SortableExerciseItemProp
482547
<TrashIcon className="h-6 w-6"/>
483548
</Button>
484549
</div>
485-
}
550+
}

codewit/package-lock.json

Lines changed: 35 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)