Hey is a decentralized and permissionless social media app built with Lens Protocol 馃尶
1import { CheckCircleIcon, PhotoIcon } from "@heroicons/react/24/outline";
2import type { ChangeEvent } from "react";
3import { memo, useEffect, useId, useState } from "react";
4import { toast } from "sonner";
5import ThumbnailsShimmer from "@/components/Shared/Shimmer/ThumbnailsShimmer";
6import { Spinner } from "@/components/Shared/UI";
7import generateVideoThumbnails from "@/helpers/generateVideoThumbnails";
8import getFileFromDataURL from "@/helpers/getFileFromDataURL";
9import { uploadFileToIPFS } from "@/helpers/uploadToIPFS";
10import { usePostAttachmentStore } from "@/store/non-persisted/post/usePostAttachmentStore";
11import { usePostVideoStore } from "@/store/non-persisted/post/usePostVideoStore";
12
13const DEFAULT_THUMBNAIL_INDEX = 0;
14export const THUMBNAIL_GENERATE_COUNT = 4;
15
16interface Thumbnail {
17 blobUrl: string;
18 decentralizedUrl: string;
19}
20
21const ChooseThumbnail = () => {
22 const inputId = useId();
23 const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
24 const [imageUploading, setImageUploading] = useState(false);
25 const [selectedThumbnailIndex, setSelectedThumbnailIndex] = useState(-1);
26 const { attachments } = usePostAttachmentStore();
27 const { setVideoThumbnail, videoThumbnail } = usePostVideoStore();
28 const { file } = attachments[0];
29
30 const uploadThumbnailToStorageNode = async (fileToUpload: File) => {
31 setVideoThumbnail({ ...videoThumbnail, uploading: true });
32 const result = await uploadFileToIPFS(fileToUpload);
33 if (!result.uri) {
34 toast.error("Failed to upload thumbnail");
35 }
36 setVideoThumbnail({
37 mimeType: fileToUpload.type || "image/jpeg",
38 uploading: false,
39 url: result.uri
40 });
41
42 return result;
43 };
44
45 const handleSelectThumbnail = (index: number) => {
46 setSelectedThumbnailIndex(index);
47 if (thumbnails[index]?.decentralizedUrl === "") {
48 setVideoThumbnail({ ...videoThumbnail, uploading: true });
49 getFileFromDataURL(
50 thumbnails[index].blobUrl,
51 "thumbnail.jpeg",
52 async (file: File) => {
53 const result = await uploadThumbnailToStorageNode(file);
54 setThumbnails(
55 thumbnails.map((thumbnail, i) => {
56 if (i === index) {
57 thumbnail.decentralizedUrl = result.uri;
58 }
59 return thumbnail;
60 })
61 );
62 }
63 );
64 } else {
65 setVideoThumbnail({
66 ...videoThumbnail,
67 uploading: false,
68 url: thumbnails[index]?.decentralizedUrl
69 });
70 }
71 };
72
73 const generateThumbnails = async (fileToGenerate: File) => {
74 try {
75 const thumbnailArray = await generateVideoThumbnails(
76 fileToGenerate,
77 THUMBNAIL_GENERATE_COUNT
78 );
79 const thumbnailList: Thumbnail[] = [];
80 for (const thumbnailBlob of thumbnailArray) {
81 thumbnailList.push({ blobUrl: thumbnailBlob, decentralizedUrl: "" });
82 }
83 setThumbnails(thumbnailList);
84 setSelectedThumbnailIndex(DEFAULT_THUMBNAIL_INDEX);
85 } catch {}
86 };
87
88 useEffect(() => {
89 handleSelectThumbnail(selectedThumbnailIndex);
90 }, [selectedThumbnailIndex]);
91
92 useEffect(() => {
93 if (file) {
94 generateThumbnails(file);
95 }
96 return () => {
97 setSelectedThumbnailIndex(-1);
98 setThumbnails([]);
99 };
100 }, [file]);
101
102 const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
103 if (event.target.files?.length) {
104 try {
105 setImageUploading(true);
106 setSelectedThumbnailIndex(-1);
107 const file = event.target.files[0];
108 const result = await uploadThumbnailToStorageNode(file);
109 const preview = window.URL?.createObjectURL(file);
110 setThumbnails([
111 { blobUrl: preview, decentralizedUrl: result.uri },
112 ...thumbnails
113 ]);
114 setSelectedThumbnailIndex(0);
115 } catch {
116 toast.error("Failed to upload thumbnail");
117 } finally {
118 setImageUploading(false);
119 }
120 }
121 };
122
123 const isUploading = videoThumbnail.uploading;
124
125 return (
126 <div className="mt-5">
127 <b>Choose Thumbnail</b>
128 <div className="mt-1 grid grid-cols-3 gap-3 py-0.5 md:grid-cols-5">
129 <label
130 className="flex h-24 w-full max-w-32 flex-none cursor-pointer flex-col items-center justify-center rounded-xl border border-gray-200 dark:border-gray-700"
131 htmlFor="chooseThumbnail"
132 >
133 <input
134 accept=".png, .jpg, .jpeg"
135 className="hidden w-full"
136 id={inputId}
137 onChange={handleUpload}
138 type="file"
139 />
140 {imageUploading ? (
141 <Spinner size="sm" />
142 ) : (
143 <>
144 <PhotoIcon className="mb-1 size-5" />
145 <span className="text-sm">Upload</span>
146 </>
147 )}
148 </label>
149 {thumbnails.length ? null : <ThumbnailsShimmer />}
150 {thumbnails.map(({ blobUrl, decentralizedUrl }, index) => {
151 const isSelected = selectedThumbnailIndex === index;
152 const isUploaded = decentralizedUrl === videoThumbnail.url;
153
154 return (
155 <button
156 className="relative"
157 disabled={isUploading}
158 key={`${blobUrl}_${index}`}
159 onClick={() => handleSelectThumbnail(index)}
160 type="button"
161 >
162 <img
163 alt="thumbnail"
164 className="h-24 w-full rounded-xl border border-gray-200 object-cover dark:border-gray-700"
165 draggable={false}
166 src={blobUrl}
167 />
168 {decentralizedUrl && isSelected && isUploaded ? (
169 <div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-100/10">
170 <CheckCircleIcon className="size-6" />
171 </div>
172 ) : null}
173 {isUploading && isSelected && (
174 <div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-100/10 backdrop-blur-md">
175 <Spinner size="sm" />
176 </div>
177 )}
178 </button>
179 );
180 })}
181 </div>
182 </div>
183 );
184};
185
186export default memo(ChooseThumbnail);