mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 09:33:42 -04:00
Add support for mp4 media in profiles
This commit is contained in:
@@ -4,7 +4,13 @@ export const DAY_MS = 24 * HOUR_MS
|
||||
export const WEEK_MS = 7 * DAY_MS
|
||||
export const MONTH_MS = 30 * DAY_MS
|
||||
export const YEAR_MS = 365 * DAY_MS
|
||||
export const HOUR_SECONDS = 60 * 60
|
||||
|
||||
export const MINUTE_SECONDS = MINUTE_MS / 1000
|
||||
export const HOUR_SECONDS = HOUR_MS / 1000
|
||||
export const DAY_SECONDS = DAY_MS / 1000
|
||||
export const WEEK_SECONDS = WEEK_MS / 1000
|
||||
export const MONTH_SECONDS = MONTH_MS / 1000
|
||||
export const YEAR_SECONDS = YEAR_MS / 1000
|
||||
|
||||
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
|
||||
@@ -2,66 +2,65 @@ import {getProfileOgImageUrl} from 'common/profiles/og-image'
|
||||
import {Profile} from 'common/profiles/profile'
|
||||
import {User} from 'common/user'
|
||||
import Image from 'next/image'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useState} from 'react'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {Modal} from 'web/components/layout/modal'
|
||||
import {ShareProfileButtons} from 'web/components/widgets/share-profile-button'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
export const PhotosModal = (props: {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
photos: string[]
|
||||
}) => {
|
||||
const {open, setOpen, photos} = props
|
||||
const [index, setIndex] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!open) setTimeout(() => setIndex(0), 100)
|
||||
}, [open])
|
||||
return (
|
||||
<Modal open={open} size={'xl'} setOpen={setOpen}>
|
||||
<Col className={MODAL_CLASS}>
|
||||
<Image
|
||||
src={photos[index]}
|
||||
width={500}
|
||||
height={700}
|
||||
alt={`preview ${index}`}
|
||||
className="h-full w-full rounded-sm object-cover"
|
||||
/>
|
||||
<Row className={'gap-2'}>
|
||||
<Button onClick={() => setIndex(index - 1)} disabled={index === 0}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={() => setIndex(index + 1)} disabled={index === photos.length - 1}>
|
||||
Next
|
||||
</Button>
|
||||
</Row>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExpandablePhoto = (props: {src: string; width?: number; height?: number}) => {
|
||||
const {src, width = 1000, height = 1000} = props
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
return (
|
||||
<div className="">
|
||||
<Image
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
alt=""
|
||||
className="cursor-pointer object-cover rounded-2xl"
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
<Modal open={open} setOpen={setOpen} size={'xl'}>
|
||||
<Image src={src} width={1000} height={1000} alt="" className={'rounded-2xl'} />
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// export const PhotosModal = (props: {
|
||||
// open: boolean
|
||||
// setOpen: (open: boolean) => void
|
||||
// photos: string[]
|
||||
// }) => {
|
||||
// const {open, setOpen, photos} = props
|
||||
// const [index, setIndex] = useState(0)
|
||||
// useEffect(() => {
|
||||
// if (!open) setTimeout(() => setIndex(0), 100)
|
||||
// }, [open])
|
||||
// return (
|
||||
// <Modal open={open} size={'xl'} setOpen={setOpen}>
|
||||
// <Col className={MODAL_CLASS}>
|
||||
// <Image
|
||||
// src={photos[index]}
|
||||
// width={500}
|
||||
// height={700}
|
||||
// alt={`preview ${index}`}
|
||||
// className="h-full w-full rounded-sm object-cover"
|
||||
// />
|
||||
// <Row className={'gap-2'}>
|
||||
// <Button onClick={() => setIndex(index - 1)} disabled={index === 0}>
|
||||
// Previous
|
||||
// </Button>
|
||||
// <Button onClick={() => setIndex(index + 1)} disabled={index === photos.length - 1}>
|
||||
// Next
|
||||
// </Button>
|
||||
// </Row>
|
||||
// </Col>
|
||||
// </Modal>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// export const ExpandablePhoto = (props: {src: string; width?: number; height?: number}) => {
|
||||
// const {src, width = 1000, height = 1000} = props
|
||||
// const [open, setOpen] = useState<boolean>(false)
|
||||
// return (
|
||||
// <div className="">
|
||||
// <Image
|
||||
// src={src}
|
||||
// width={width}
|
||||
// height={height}
|
||||
// alt=""
|
||||
// className="cursor-pointer object-cover rounded-2xl"
|
||||
// onClick={() => setOpen(true)}
|
||||
// />
|
||||
// <Modal open={open} setOpen={setOpen} size={'xl'}>
|
||||
// <Image src={src} width={1000} height={1000} alt="" className={'rounded-2xl'} />
|
||||
// </Modal>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
export const ViewProfileCardButton = (props: {
|
||||
user: User
|
||||
|
||||
@@ -6,6 +6,7 @@ import {Col} from 'web/components/layout/col'
|
||||
import {Modal} from 'web/components/layout/modal'
|
||||
import {Carousel} from 'web/components/widgets/carousel'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {isVideo} from 'web/lib/firebase/storage'
|
||||
|
||||
import {SignUpButton} from './nav/sidebar'
|
||||
|
||||
@@ -15,23 +16,8 @@ export default function ProfileCarousel(props: {profile: Profile; refreshProfile
|
||||
|
||||
const [lightboxUrl, setLightboxUrl] = useState('')
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
// const [isEditMode, setIsEditMode] = useState(false)
|
||||
// const [addPhotosOpen, setAddPhotosOpen] = useState(false)
|
||||
|
||||
// const [pinnedUrl, setPinnedUrl] = useState<string | null>(profile.pinned_url)
|
||||
// const [photoUrls, setPhotoUrls] = useState<string[]>(profile.photo_urls ?? [])
|
||||
|
||||
const currentUser = useUser()
|
||||
// const isCurrentUser = currentUser?.id === profile.user_id
|
||||
|
||||
// const handleSaveChanges = async () => {
|
||||
// await updateProfile({
|
||||
// pinned_url: pinnedUrl ?? undefined,
|
||||
// photo_urls: photoUrls,
|
||||
// })
|
||||
// setIsEditMode(false)
|
||||
// refreshProfile()
|
||||
// }
|
||||
|
||||
if (photoNums == 0 && !profile.pinned_url) return
|
||||
|
||||
@@ -70,138 +56,61 @@ export default function ProfileCarousel(props: {profile: Profile; refreshProfile
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*<div className="flex gap-2 self-end">*/}
|
||||
{/* {isCurrentUser && !isEditMode && (*/}
|
||||
{/* <Button*/}
|
||||
{/* onClick={() => setIsEditMode(true)}*/}
|
||||
{/* color="gray-outline"*/}
|
||||
{/* size="sm"*/}
|
||||
{/* >*/}
|
||||
{/* Edit photos*/}
|
||||
{/* </Button>*/}
|
||||
{/* )}*/}
|
||||
{/* {isCurrentUser && isEditMode && (*/}
|
||||
{/* <Row className="gap-2">*/}
|
||||
{/* <Button*/}
|
||||
{/* onClick={() => {*/}
|
||||
{/* // TODO this is stale if you've saved*/}
|
||||
{/* setPhotoUrls(profile.photo_urls ?? [])*/}
|
||||
{/* setPinnedUrl(profile.pinned_url)*/}
|
||||
{/* setIsEditMode(false)*/}
|
||||
{/* }}*/}
|
||||
{/* color="gray-outline"*/}
|
||||
{/* size="sm"*/}
|
||||
{/* >*/}
|
||||
{/* Cancel*/}
|
||||
{/* </Button>*/}
|
||||
{/* <Button onClick={handleSaveChanges} size="sm">*/}
|
||||
{/* Save changes*/}
|
||||
{/* </Button>*/}
|
||||
{/* </Row>*/}
|
||||
{/* )}*/}
|
||||
{/*</div>*/}
|
||||
|
||||
{/*{isEditMode ? (*/}
|
||||
{/* <Col className="gap-4">*/}
|
||||
{/* <EditablePhotoGrid*/}
|
||||
{/* photos={buildArray(pinnedUrl, photoUrls)}*/}
|
||||
{/* onReorder={(newOrder) => {*/}
|
||||
{/* const newPinnedUrl = newOrder[0]*/}
|
||||
{/* const newPhotoUrls = newOrder.filter(*/}
|
||||
{/* (url) => url !== newPinnedUrl*/}
|
||||
{/* )*/}
|
||||
{/* setPinnedUrl(newPinnedUrl)*/}
|
||||
{/* setPhotoUrls(newPhotoUrls)*/}
|
||||
{/* }}*/}
|
||||
{/* onDelete={(url) => {*/}
|
||||
{/* if (url === pinnedUrl) {*/}
|
||||
{/* const newPhotos = photoUrls.filter((u) => u !== url)*/}
|
||||
{/* setPinnedUrl(newPhotos[0] ?? null)*/}
|
||||
{/* setPhotoUrls(newPhotos.slice(1))*/}
|
||||
{/* } else {*/}
|
||||
{/* setPhotoUrls(photoUrls.filter((u) => u !== url))*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/* onSetProfilePic={(url) => {*/}
|
||||
{/* if (url === pinnedUrl) return*/}
|
||||
{/* setPinnedUrl(url)*/}
|
||||
{/* setPhotoUrls(*/}
|
||||
{/* [...photoUrls.filter((u) => u !== url), pinnedUrl].filter(*/}
|
||||
{/* Boolean*/}
|
||||
{/* ) as string[]*/}
|
||||
{/* )*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/* <Button*/}
|
||||
{/* onClick={() => setAddPhotosOpen(true)}*/}
|
||||
{/* color="gray-outline"*/}
|
||||
{/* size="sm"*/}
|
||||
{/* className="self-start"*/}
|
||||
{/* >*/}
|
||||
{/* <PlusIcon className="mr-1 h-5 w-5"/>*/}
|
||||
{/* Add photos*/}
|
||||
{/* </Button>*/}
|
||||
{/* </Col>*/}
|
||||
{/*) : (*/}
|
||||
<Carousel>
|
||||
{buildArray(profile.pinned_url, profile.photo_urls).map((url, i) => (
|
||||
<Col key={url}>
|
||||
<div className="h-[300px] w-[300px] flex-none snap-start">
|
||||
<Image
|
||||
priority={i < 3}
|
||||
src={url}
|
||||
height={300}
|
||||
width={300}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
className="h-full cursor-pointer rounded object-cover"
|
||||
onClick={() => {
|
||||
setLightboxUrl(url)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
height={300}
|
||||
width={300}
|
||||
className="h-full w-full cursor-pointer rounded object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
onClick={() => {
|
||||
setLightboxUrl(url)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
priority={i < 3}
|
||||
src={url}
|
||||
height={300}
|
||||
width={300}
|
||||
sizes="(max-width: 640px) 100vw, 300px"
|
||||
alt=""
|
||||
className="h-full cursor-pointer rounded object-cover"
|
||||
onClick={() => {
|
||||
setLightboxUrl(url)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 px-4 py-1 text-sm w-[300px] whitespace-pre-wrap">
|
||||
{(profile.image_descriptions as Record<string, string>)?.[url]}
|
||||
</p>
|
||||
</Col>
|
||||
))}
|
||||
{/*{isCurrentUser && (profile.photo_urls?.length ?? 0) > 1 && (*/}
|
||||
{/* <button*/}
|
||||
{/* className="bg-ink-200 text-ink-0 group flex h-[300px] w-[300px] flex-none cursor-pointer snap-start items-center justify-center rounded ease-in-out"*/}
|
||||
{/* onClick={() => setAddPhotosOpen(true)}*/}
|
||||
{/* >*/}
|
||||
{/* <PlusIcon className="w-20 transition-all group-hover:w-24"/>*/}
|
||||
{/* </button>*/}
|
||||
{/*)}*/}
|
||||
</Carousel>
|
||||
{/* )}*/}
|
||||
|
||||
<Modal open={lightboxOpen} setOpen={setLightboxOpen}>
|
||||
<Image src={lightboxUrl} width={1000} height={1000} alt="" />
|
||||
{isVideo(lightboxUrl) ? (
|
||||
<video
|
||||
src={lightboxUrl}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-[80vh] w-full rounded"
|
||||
/>
|
||||
) : (
|
||||
<Image src={lightboxUrl} width={1000} height={1000} alt="" />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/*{isCurrentUser && (*/}
|
||||
{/* <Modal open={addPhotosOpen} setOpen={setAddPhotosOpen}>*/}
|
||||
{/* <Col className={clsx(MODAL_CLASS)}>*/}
|
||||
{/* <AddPhotosWidget*/}
|
||||
{/* user={currentUser}*/}
|
||||
{/* photo_urls={photoUrls}*/}
|
||||
{/* pinned_url={pinnedUrl}*/}
|
||||
{/* setPhotoUrls={setPhotoUrls}*/}
|
||||
{/* setPinnedUrl={setPinnedUrl}*/}
|
||||
{/* />*/}
|
||||
{/* <Row className="gap-4 self-end">*/}
|
||||
{/* <Button*/}
|
||||
{/* color="gray-outline"*/}
|
||||
{/* onClick={() => setAddPhotosOpen(false)}*/}
|
||||
{/* >*/}
|
||||
{/* Done*/}
|
||||
{/* </Button>*/}
|
||||
{/* </Row>*/}
|
||||
{/* </Col>*/}
|
||||
{/* </Modal>*/}
|
||||
{/*)}*/}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import {buildArray} from 'common/util/array'
|
||||
import {uniq} from 'lodash'
|
||||
import Image from 'next/image'
|
||||
import {useState} from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {uploadImage} from 'web/lib/firebase/storage'
|
||||
import {isVideo, uploadImage} from 'web/lib/firebase/storage'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
export const AddPhotosWidget = (props: {
|
||||
@@ -48,6 +49,7 @@ export const AddPhotosWidget = (props: {
|
||||
selectedFiles.map((f) => uploadImage(username, f, 'love-images')),
|
||||
).catch((e) => {
|
||||
console.error(e)
|
||||
toast.error(e)
|
||||
return []
|
||||
})
|
||||
if (!pinned_url) setPinnedUrl(urls[0])
|
||||
@@ -116,13 +118,26 @@ export const AddPhotosWidget = (props: {
|
||||
>
|
||||
<XMarkIcon className={'h-4 w-4'} />
|
||||
</Button>
|
||||
<Image
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
alt={`preview ${index}`}
|
||||
className="h-[200px] w-[200px] object-cover"
|
||||
/>
|
||||
{isVideo(url) ? (
|
||||
<video
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-[200px] w-[200px] object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={url}
|
||||
width={80}
|
||||
height={80}
|
||||
alt={`preview ${index}`}
|
||||
className="h-[200px] w-[200px] object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
// stop click bubbling so clicking/focusing the input doesn't pin the image
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import {captureMessage} from '@sentry/nextjs'
|
||||
import {YEAR_SECONDS} from 'common/util/time'
|
||||
import Compressor from 'compressorjs'
|
||||
import {getDownloadURL, ref, uploadBytesResumable} from 'firebase/storage'
|
||||
import {nanoid} from 'nanoid'
|
||||
|
||||
import {storage} from './init'
|
||||
|
||||
const ONE_YEAR_SECS = 60 * 60 * 24 * 365
|
||||
|
||||
const isHeic = (file: File) =>
|
||||
file.type === 'image/heic' || file.type === 'image/heif' || /\.heic$/i.test(file.name)
|
||||
|
||||
export const uploadImage = async (
|
||||
username: string,
|
||||
file: File,
|
||||
prefix?: string,
|
||||
onProgress?: (progress: number, isRunning: boolean) => void,
|
||||
) => {
|
||||
const fileType = await getFileType(file)
|
||||
|
||||
const lastDot = file.name.lastIndexOf('.')
|
||||
let ext = lastDot !== -1 ? file.name.slice(lastDot + 1) : undefined
|
||||
ext ??= fileType?.split('/')[1]
|
||||
ext ??= 'bin'
|
||||
ext = ext.toLowerCase()
|
||||
|
||||
// Replace filename with a nanoid to avoid collisions
|
||||
let [, ext] = file.name.split('.')
|
||||
const stem = nanoid(10)
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
@@ -28,8 +34,8 @@ export const uploadImage = async (
|
||||
'image/heif',
|
||||
]
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
// throw new Error('Unsupported image format')
|
||||
if (!ALLOWED_TYPES.includes(fileType || '')) {
|
||||
captureMessage('Likely unsupported image format', {attributes: {type: file.type}})
|
||||
console.warn('Likely unsupported image format', file.type)
|
||||
}
|
||||
|
||||
@@ -44,6 +50,7 @@ export const uploadImage = async (
|
||||
}
|
||||
|
||||
const filename = `${stem}.${ext}`
|
||||
console.log('filename', filename)
|
||||
const storageRef = ref(
|
||||
storage,
|
||||
`user-images/${username}${prefix ? '/' + prefix : ''}/${filename}`,
|
||||
@@ -68,7 +75,7 @@ export const uploadImage = async (
|
||||
}
|
||||
|
||||
const uploadTask = uploadBytesResumable(storageRef, file, {
|
||||
cacheControl: `public, max-age=${ONE_YEAR_SECS}`,
|
||||
cacheControl: `public, max-age=${YEAR_SECONDS}`,
|
||||
})
|
||||
|
||||
let resolvePromise: (url: string) => void
|
||||
@@ -130,6 +137,9 @@ export async function convertWebpToJpeg(file: File): Promise<File> {
|
||||
})
|
||||
}
|
||||
|
||||
const isHeic = (file: File) =>
|
||||
file.type === 'image/heic' || file.type === 'image/heif' || /\.heic$/i.test(file.name)
|
||||
|
||||
export async function convertHeicToJpeg(file: File): Promise<File> {
|
||||
// Convert HEIC → JPEG immediately (as HEIC not rendered)
|
||||
// heic2any available in client only
|
||||
@@ -144,3 +154,31 @@ export async function convertHeicToJpeg(file: File): Promise<File> {
|
||||
type: 'image/jpeg',
|
||||
})
|
||||
}
|
||||
|
||||
async function getFileType(file: File): Promise<string | undefined> {
|
||||
if (file.type) return file.type
|
||||
|
||||
const buf = await file.slice(0, 12).arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
|
||||
// MP4/MOV: bytes 4-8 = "ftyp"
|
||||
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70)
|
||||
return 'video/mp4'
|
||||
|
||||
// WebM: starts with 0x1A45DFA3
|
||||
if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3)
|
||||
return 'video/webm'
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
|
||||
|
||||
// PNG: 89 50 4E 47
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
||||
return 'image/png'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function isVideo(url: string) {
|
||||
return url.match(/\.(mp4|webm|mov|ogg)/)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user