Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Video support. #40

Merged
merged 12 commits into from
Mar 4, 2024
8 changes: 8 additions & 0 deletions src/components/home/update-username.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function UpdateUsername(): JSX.Element {
const [available, setAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [visited, setVisited] = useState(false);
const [searching, setSearching] = useState(false);
const [inputValue, setInputValue] = useState('');
const [errorMessage, setErrorMessage] = useState('');

Expand All @@ -28,13 +29,17 @@ export function UpdateUsername(): JSX.Element {

useEffect(() => {
const checkAvailability = async (value: string): Promise<void> => {
setSearching(true);

const empty = await checkUsernameAvailability(value);

if (empty) setAvailable(true);
else {
setAvailable(false);
setErrorMessage('This username has been taken. Please choose another.');
}

setSearching(false);
};

if (!visited && inputValue.length > 0) setVisited(true);
Expand Down Expand Up @@ -63,6 +68,8 @@ export function UpdateUsername(): JSX.Element {

if (!available) return;

if (searching) return;

setLoading(true);

await sleep(500);
Expand All @@ -82,6 +89,7 @@ export function UpdateUsername(): JSX.Element {

const cancelUpdateUsername = (): void => {
closeModal();

if (!alreadySet) void updateUsername(user?.id as string);
};

Expand Down
126 changes: 83 additions & 43 deletions src/components/input/image-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import cn from 'clsx';
import { useModal } from '@lib/hooks/useModal';
Expand Down Expand Up @@ -50,6 +50,8 @@ export function ImagePreview({
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedImage, setSelectedImage] = useState<ImageData | null>(null);

const videoRef = useRef<HTMLVideoElement>(null);

const { open, openModal, closeModal } = useModal();

useEffect(() => {
Expand All @@ -58,7 +60,13 @@ export function ImagePreview({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);

const handleSelectedImage = (index: number) => () => {
const handleVideoStop = (): void => {
if (videoRef.current) videoRef.current.pause();
};

const handleSelectedImage = (index: number, isVideo?: boolean) => () => {
if (isVideo) handleVideoStop();

setSelectedIndex(index);
openModal();
};
Expand Down Expand Up @@ -106,52 +114,84 @@ export function ImagePreview({
/>
</Modal>
<AnimatePresence mode='popLayout'>
{imagesPreview.map(({ id, src, alt }, index) => (
<motion.button
type='button'
className={cn(
'accent-tab relative transition-shadow',
isTweet
? postImageBorderRadius[previewCount][index]
: 'rounded-2xl',
{
'col-span-2 row-span-2': previewCount === 1,
'row-span-2':
previewCount === 2 || (index === 0 && previewCount === 3)
}
)}
{...variants}
onClick={preventBubbling(handleSelectedImage(index))}
layout={!isTweet ? true : false}
key={id}
>
<NextImage
className='relative h-full w-full cursor-pointer transition
hover:brightness-75 hover:duration-200'
imgClassName={cn(
{imagesPreview.map(({ id, src, alt }, index) => {
const isVideo = imagesPreview[index].type?.includes('video');

return (
<motion.button
type='button'
className={cn(
'accent-tab group relative transition-shadow',
isTweet
? postImageBorderRadius[previewCount][index]
: 'rounded-2xl'
: 'rounded-2xl',
{
'col-span-2 row-span-2': previewCount === 1,
'row-span-2':
previewCount === 2 || (index === 0 && previewCount === 3)
}
)}
{...variants}
onClick={preventBubbling(handleSelectedImage(index, isVideo))}
layout={!isTweet ? true : false}
key={id}
>
{isVideo ? (
<>
<Button
className='visible absolute top-0 right-0 z-10 -translate-x-1 translate-y-1
bg-light-primary/75 p-1 opacity-0 backdrop-blur-sm transition
hover:bg-image-preview-hover/75 group-hover:opacity-100 xs:invisible'
>
<HeroIcon className='h-5 w-5' iconName='ArrowUpRightIcon' />
</Button>
<video
ref={videoRef}
className={cn(
`relative h-full w-full cursor-pointer transition
hover:brightness-75 hover:duration-200`,
isTweet
? postImageBorderRadius[previewCount][index]
: 'rounded-2xl'
)}
src={src}
controls
muted
/>
</>
) : (
<NextImage
className='relative h-full w-full cursor-pointer transition
hover:brightness-75 hover:duration-200'
imgClassName={cn(
isTweet
? postImageBorderRadius[previewCount][index]
: 'rounded-2xl'
)}
previewCount={previewCount}
layout='fill'
src={src}
alt={alt}
useSkeleton={isTweet}
/>
)}
previewCount={previewCount}
layout='fill'
src={src}
alt={alt}
useSkeleton={isTweet}
/>
{removeImage && (
<Button
className='group absolute top-0 left-0 translate-x-1 translate-y-1
{removeImage && (
<Button
className='group absolute top-0 left-0 translate-x-1 translate-y-1
bg-light-primary/75 p-1 backdrop-blur-sm
hover:bg-image-preview-hover/75'
onClick={preventBubbling(removeImage(id))}
>
<HeroIcon className='h-5 w-5 text-white' iconName='XMarkIcon' />
<ToolTip className='translate-y-2' tip='Remove' />
</Button>
)}
</motion.button>
))}
onClick={preventBubbling(removeImage(id))}
>
<HeroIcon
className='h-5 w-5 text-white'
iconName='XMarkIcon'
/>
<ToolTip className='translate-y-2' tip='Remove' />
</Button>
)}
</motion.button>
);
})}
</AnimatePresence>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/input/input-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function InputOptions({
<input
className='hidden'
type='file'
accept='image/*'
accept='image/*,video/*'
onChange={handleImageUpload}
ref={inputFileRef}
multiple
Expand Down
5 changes: 4 additions & 1 deletion src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ export function Input({

const files = isClipboardEvent ? e.clipboardData.files : e.target.files;

const imagesData = getImagesData(files, previewCount);
const imagesData = getImagesData(files, {
currentFiles: previewCount,
allowUploadingVideos: true
});

if (!imagesData) {
toast.error('Please choose a GIF or photo up to 4');
Expand Down
76 changes: 50 additions & 26 deletions src/components/modal/image-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function ImageModal({
const [indexes, setIndexes] = useState<number[]>([]);
const [loading, setLoading] = useState(true);

const { src, alt } = imageData;
const { src, alt, type } = imageData;

const isVideo = type?.includes('video');

const requireArrows = handleNextIndex && previewCount > 1;

Expand All @@ -51,9 +53,14 @@ export function ImageModal({
setIndexes([...indexes, selectedIndex]);
}

const image = new Image();
image.src = src;
image.onload = (): void => setLoading(false);
const media = isVideo ? document.createElement('video') : new Image();

media.src = src;

const handleLoadingCompleted = (): void => setLoading(false);

if (isVideo) media.onloadeddata = handleLoadingCompleted;
else media.onload = handleLoadingCompleted;
}, [...(tweet && previewCount > 1 ? [src] : [])]);

useEffect(() => {
Expand Down Expand Up @@ -103,28 +110,45 @@ export function ImageModal({
</motion.div>
) : (
<motion.div className='relative mx-auto' {...modal} key={src}>
<picture className='group relative flex max-w-3xl'>
<source srcSet={src} type='image/*' />
<img
className='max-h-[75vh] rounded-md object-contain md:max-h-[80vh]'
src={src}
alt={alt}
onClick={preventBubbling()}
/>
<a
className='trim-alt accent-tab absolute bottom-0 right-0 mx-2 mb-2 translate-y-4
rounded-md bg-main-background/40 px-2 py-1 text-sm text-light-primary/80 opacity-0
transition hover:bg-main-accent hover:text-white focus-visible:translate-y-0
focus-visible:bg-main-accent focus-visible:text-white focus-visible:opacity-100
group-hover:translate-y-0 group-hover:opacity-100 dark:text-dark-primary/80'
href={src}
target='_blank'
rel='noreferrer'
onClick={preventBubbling(null, true)}
>
{alt}
</a>
</picture>
{isVideo ? (
<div className='group relative flex max-w-3xl'>
<video
className={cn(
'max-h-[75vh] rounded-md object-contain md:max-h-[80vh]',
loading ? 'hidden' : 'block'
)}
src={src}
autoPlay
controls
onClick={preventBubbling()}
>
<source srcSet={src} type='video/*' />
</video>
</div>
) : (
<picture className='group relative flex max-w-3xl'>
<source srcSet={src} type='image/*' />
<img
className='max-h-[75vh] rounded-md object-contain md:max-h-[80vh]'
src={src}
alt={alt}
onClick={preventBubbling()}
/>
<a
className='trim-alt accent-tab absolute bottom-0 right-0 mx-2 mb-2 translate-y-4
rounded-md bg-main-background/40 px-2 py-1 text-sm text-light-primary/80 opacity-0
transition hover:bg-main-accent hover:text-white focus-visible:translate-y-0
focus-visible:bg-main-accent focus-visible:text-white focus-visible:opacity-100
group-hover:translate-y-0 group-hover:opacity-100 dark:text-dark-primary/80'
href={src}
target='_blank'
rel='noreferrer'
onClick={preventBubbling(null, true)}
>
{alt}
</a>
</picture>
)}
<a
className='custom-underline absolute left-0 -bottom-7 font-medium text-light-primary/80
decoration-transparent underline-offset-2 transition hover:text-light-primary hover:underline
Expand Down
1 change: 1 addition & 0 deletions src/components/tweet/tweet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function Tweet(tweet: TweetProps): JSX.Element {
? 'mt-0.5 pt-2.5 pb-0'
: 'border-b border-light-border dark:border-dark-border'
)}
draggable={false}
onClick={delayScroll(200)}
>
<div className='grid grid-cols-[auto,1fr] gap-x-3 gap-y-1'>
Expand Down
7 changes: 4 additions & 3 deletions src/lib/context/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
userBookmarksCollection
} from '@lib/firebase/collections';
import { getRandomId, getRandomInt } from '@lib/random';
import { checkUsernameAvailability } from '@lib/firebase/utils';
import type { ReactNode } from 'react';
import type { User as AuthUser } from 'firebase/auth';
import type { WithFieldValue } from 'firebase/firestore';
Expand Down Expand Up @@ -67,11 +68,11 @@ export function AuthContextProvider({

randomUsername = `${normalizeName as string}${randomInt}`;

const randomUserSnapshot = await getDoc(
doc(usersCollection, randomUsername)
const isUsernameAvailable = await checkUsernameAvailability(
randomUsername
);

if (!randomUserSnapshot.exists()) available = true;
if (isUsernameAvailable) available = true;
}

const userData: WithFieldValue<User> = {
Expand Down
15 changes: 5 additions & 10 deletions src/lib/firebase/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,20 +132,15 @@ export async function uploadImages(

const imagesPreview = await Promise.all(
files.map(async (file) => {
let src: string;
const { id, name: alt, type } = file;

const { id, name: alt } = file;
const storageRef = ref(storage, `images/${userId}/${id}`);

const storageRef = ref(storage, `images/${userId}/${alt}`);
await uploadBytesResumable(storageRef, file);

try {
src = await getDownloadURL(storageRef);
} catch {
await uploadBytesResumable(storageRef, file);
src = await getDownloadURL(storageRef);
}
const src = await getDownloadURL(storageRef);

return { id, src, alt };
return { id, src, alt, type };
})
);

Expand Down
1 change: 1 addition & 0 deletions src/lib/types/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type ImageData = {
src: string;
alt: string;
type?: string;
};

export type ImagesPreview = (ImageData & {
Expand Down
Loading
Loading