Add support for mp4 media in profiles

This commit is contained in:
MartinBraquet
2026-03-15 13:42:20 +01:00
parent 5a8c698ed5
commit 0592c7e766
5 changed files with 173 additions and 206 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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>*/}
{/*)}*/}
</>
)
}

View File

@@ -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

View File

@@ -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)/)
}