feat: add unsplash image chooser for courses thumbnails

This commit is contained in:
swve 2024-10-10 22:06:27 +02:00
parent 3f1e9ecb7f
commit cc68ea2e94
5 changed files with 222 additions and 25 deletions

View file

@ -3,10 +3,11 @@ import { useOrg } from '@components/Contexts/OrgContext'
import { getAPIUrl } from '@services/config/config'
import { updateCourseThumbnail } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { ArrowBigUpDash, UploadCloud } from 'lucide-react'
import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import React from 'react'
import React, { useState } from 'react'
import { mutate } from 'swr'
import UnsplashImagePicker from './UnsplashImagePicker'
function ThumbnailUpdate() {
const course = useCourse() as any
@ -15,10 +16,24 @@ function ThumbnailUpdate() {
const [localThumbnail, setLocalThumbnail] = React.useState(null) as any
const [isLoading, setIsLoading] = React.useState(false) as any
const [error, setError] = React.useState('') as any
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
const handleFileChange = async (event: any) => {
const file = event.target.files[0]
setLocalThumbnail(file)
await updateThumbnail(file)
}
const handleUnsplashSelect = async (imageUrl: string) => {
setIsLoading(true)
const response = await fetch(imageUrl)
const blob = await response.blob()
const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' })
setLocalThumbnail(file)
await updateThumbnail(file)
}
const updateThumbnail = async (file: File) => {
setIsLoading(true)
const res = await updateCourseThumbnail(
course.courseStructure.course_uuid,
@ -49,8 +64,7 @@ function ThumbnailUpdate() {
{localThumbnail ? (
<img
src={URL.createObjectURL(localThumbnail)}
className={`${isLoading ? 'animate-pulse' : ''
} shadow w-[200px] h-[100px] rounded-md`}
className={`${isLoading ? 'animate-pulse' : ''} shadow w-[200px] h-[100px] rounded-md`}
/>
) : (
<img
@ -65,19 +79,13 @@ function ThumbnailUpdate() {
</div>
{isLoading ? (
<div className="flex justify-center items-center">
<input
type="file"
id="fileInput"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div className="font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
<div className="font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
</div>
) : (
<div className="flex justify-center items-center">
<div className="flex justify-center items-center space-x-2">
<input
type="file"
id="fileInput"
@ -85,16 +93,29 @@ function ThumbnailUpdate() {
onChange={handleFileChange}
/>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
<span>Upload Image</span>
</button>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
onClick={() => setShowUnsplashPicker(true)}
>
<ImageIcon size={16} className="mr-2" />
<span>Choose from Gallery</span>
</button>
</div>
)}
</div>
</div>
{showUnsplashPicker && (
<UnsplashImagePicker
onSelect={handleUnsplashSelect}
onClose={() => setShowUnsplashPicker(false)}
/>
)}
</div>
)
}

View file

@ -0,0 +1,166 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createApi } from 'unsplash-js';
import { Search, X, Cpu, Briefcase, GraduationCap, Heart, Palette, Plane, Utensils,
Dumbbell, Music, Shirt, Book, Building, Bike, Camera, Microscope, Coins, Coffee, Gamepad,
Flower} from 'lucide-react';
const unsplash = createApi({
accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as string,
});
const IMAGES_PER_PAGE = 20;
const predefinedLabels = [
{ name: 'Nature', icon: Flower },
{ name: 'Technology', icon: Cpu },
{ name: 'Business', icon: Briefcase },
{ name: 'Education', icon: GraduationCap },
{ name: 'Health', icon: Heart },
{ name: 'Art', icon: Palette },
{ name: 'Science', icon: Microscope },
{ name: 'Travel', icon: Plane },
{ name: 'Food', icon: Utensils },
{ name: 'Sports', icon: Dumbbell },
{ name: 'Music', icon: Music },
{ name: 'Fashion', icon: Shirt },
{ name: 'History', icon: Book },
{ name: 'Architecture', icon: Building },
{ name: 'Fitness', icon: Bike },
{ name: 'Photography', icon: Camera },
{ name: 'Biology', icon: Microscope },
{ name: 'Finance', icon: Coins },
{ name: 'Lifestyle', icon: Coffee },
{ name: 'Gaming', icon: Gamepad },
];
interface UnsplashImagePickerProps {
onSelect: (imageUrl: string) => void;
onClose: () => void;
}
const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onClose }) => {
const [query, setQuery] = useState('');
const [images, setImages] = useState<any[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const fetchImages = useCallback(async (searchQuery: string, pageNum: number) => {
setLoading(true);
try {
const result = await unsplash.search.getPhotos({
query: searchQuery,
page: pageNum,
perPage: IMAGES_PER_PAGE,
});
if (result && result.response) {
setImages(prevImages => pageNum === 1 ? result.response.results : [...prevImages, ...result.response.results]);
} else {
console.error('Unexpected response structure:', result);
}
} catch (error) {
console.error('Error fetching images:', error);
} finally {
setLoading(false);
}
}, []);
const debouncedFetchImages = useCallback(
debounce((searchQuery: string) => {
setPage(1);
fetchImages(searchQuery, 1);
}, 300),
[fetchImages]
);
useEffect(() => {
if (query) {
debouncedFetchImages(query);
}
}, [query, debouncedFetchImages]);
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const handleLabelClick = (label: string) => {
setQuery(label);
};
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
fetchImages(query, nextPage);
};
const handleImageSelect = (imageUrl: string) => {
onSelect(imageUrl);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-3/4 max-w-4xl max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Choose an image from Unsplash</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={24} />
</button>
</div>
<div className="relative mb-4">
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search for images..."
className="w-full p-2 pl-10 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
</div>
<div className="flex flex-wrap gap-2 mb-4">
{predefinedLabels.map(label => (
<button
key={label.name}
onClick={() => handleLabelClick(label.name)}
className="px-3 py-1 bg-neutral-100 rounded-lg hover:bg-neutral-200 nice-shadow transition-colors flex items-center gap-1 space-x-1"
>
<label.icon size={16} />
<span>{label.name}</span>
</button>
))}
</div>
<div className="grid grid-cols-3 gap-4">
{images.map(image => (
<div key={image.id} className="relative w-full pb-[56.25%]">
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute inset-0 w-full h-full object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageSelect(image.urls.full)}
/>
</div>
))}
</div>
{loading && <p className="text-center mt-4">Loading...</p>}
{!loading && images.length > 0 && (
<button
onClick={handleLoadMore}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Load More
</button>
)}
</div>
</div>
);
};
// Custom debounce function
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout;
return (...args: any[]) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
};
export default UnsplashImagePicker;

View file

@ -175,7 +175,7 @@ function UserEditGeneral() {
}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
<span>Change Avatar</span>
</button>
</div>
)}

View file

@ -67,6 +67,7 @@
"tailwind-merge": "^2.5.3",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7",
"unsplash-js": "^7.0.19",
"uuid": "^9.0.1",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.12",

View file

@ -176,6 +176,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.13)
unsplash-js:
specifier: ^7.0.19
version: 7.0.19
uuid:
specifier: ^9.0.1
version: 9.0.1
@ -3818,6 +3821,10 @@ packages:
unplugin@1.0.1:
resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==}
unsplash-js@7.0.19:
resolution: {integrity: sha512-j6qT2floy5Q2g2d939FJpwey1yw/GpQecFiSouyJtsHQPj3oqmqq3K4rI+GF8vU1zwGCT7ZwIGQd2dtCQLjYJw==}
engines: {node: '>=10'}
update-browserslist-db@1.1.1:
resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
hasBin: true
@ -6271,8 +6278,8 @@ snapshots:
'@typescript-eslint/parser': 8.8.1(eslint@8.57.1)(typescript@5.4.4)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.1)
eslint-plugin-react: 7.37.1(eslint@8.57.1)
eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1)
@ -6291,37 +6298,37 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1):
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.3.7
enhanced-resolve: 5.17.1
eslint: 8.57.1
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
is-bun-module: 1.2.1
is-glob: 4.0.3
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-node
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.8.1(eslint@8.57.1)(typescript@5.4.4)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@ -6332,7 +6339,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.8.1(eslint@8.57.1)(typescript@5.4.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@ -8034,6 +8041,8 @@ snapshots:
webpack-sources: 3.2.3
webpack-virtual-modules: 0.5.0
unsplash-js@7.0.19: {}
update-browserslist-db@1.1.1(browserslist@4.24.0):
dependencies:
browserslist: 4.24.0