'use client'; import React, { ChangeEvent, ClipboardEvent, FC, Fragment, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Button } from '@gitroom/react/form/button'; import useSWR from 'swr'; import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; import { hasExtension } from '@gitroom/helpers/utils/has.extension'; import { Media } from '@prisma/client'; import { useMediaDirectory } from '@gitroom/react/helpers/use.media.directory'; import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; import EventEmitter from 'events'; import { useToaster } from '@gitroom/react/toaster/toaster'; import clsx from 'clsx'; import { VideoFrame } from '@gitroom/react/helpers/video.frame'; import { useUppyUploader } from '@gitroom/frontend/components/media/new.uploader'; import dynamic from 'next/dynamic'; import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { AiImage } from '@gitroom/frontend/components/launches/ai.image'; import { DropFiles } from '@gitroom/frontend/components/layout/drop.files'; import { deleteDialog } from '@gitroom/react/helpers/delete.dialog'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { ThirdPartyMedia } from '@gitroom/frontend/components/third-parties/third-party.media'; import { ReactSortable } from 'react-sortablejs'; import { MediaComponentInner } from '@gitroom/frontend/components/launches/helpers/media.settings.component'; import { AiVideo } from '@gitroom/frontend/components/launches/ai.video'; import { useModals } from '@gitroom/frontend/components/layout/new-modal'; import { ThirdPartyMediaLibrary } from '@gitroom/frontend/components/third-parties/third-party.media-library'; import { Dashboard } from '@uppy/react'; import { ChevronLeftIcon, ChevronRightIcon, PlusIcon, DeleteCircleIcon, CloseCircleIcon, DragHandleIcon, MediaSettingsIcon, InsertMediaIcon, DesignMediaIcon, VerticalDividerIcon, NoMediaIcon, } from '@gitroom/frontend/components/ui/icons'; import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; import { useShallow } from 'zustand/react/shallow'; import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; import { useDebounce } from 'use-debounce'; const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); const showModalEmitter = new EventEmitter(); export const Pagination: FC<{ current: number; totalPages: number; setPage: (num: number) => void; }> = (props) => { const t = useT(); const { current, totalPages, setPage } = props; const paginationItems = useMemo(() => { // Convert to 1-based for algorithm (current is 0-based) const c = current + 1; const m = totalPages; // If total pages <= 10, show all pages if (m <= 10) { return Array.from({ length: m }, (_, i) => i + 1); } const delta = 3; const left = c - delta; const right = c + delta + 1; const range: number[] = []; const rangeWithDots: (number | '...')[] = []; let l: number | undefined; // Build the range of pages to show for (let i = 1; i <= m; i++) { if (i === 1 || i === m || (i >= left && i < right)) { range.push(i); } } // Add dots where there are gaps for (const i of range) { if (l !== undefined) { if (i - l === 2) { rangeWithDots.push(l + 1); } else if (i - l !== 1) { rangeWithDots.push('...'); } } rangeWithDots.push(i); l = i; } // Limit to maximum 10 items by trimming pages near edges if needed while (rangeWithDots.length > 10) { const currentIndex = rangeWithDots.findIndex((item) => item === c); if (currentIndex !== -1 && currentIndex > rangeWithDots.length / 2) { // Current is in second half, remove one item from start side rangeWithDots.splice(2, 1); } else { // Current is in first half, remove one item from end side rangeWithDots.splice(-3, 1); } } return rangeWithDots; }, [current, totalPages]); return ( ); }; export const ShowMediaBoxModal: FC = () => { const [showModal, setShowModal] = useState(false); const [callBack, setCallBack] = useState<(params: { id: string; path: string }[]) => void | undefined>(); const closeModal = useCallback(() => { setShowModal(false); setCallBack(undefined); }, []); useEffect(() => { showModalEmitter.on('show-modal', (cCallback) => { setShowModal(true); setCallBack(() => cCallback); }); return () => { showModalEmitter.removeAllListeners('show-modal'); }; }, []); if (!showModal) return null; return (
); }; export const showMediaBox = ( callback: (params: { id: string; path: string }) => void ) => { showModalEmitter.emit('show-modal', callback); }; const CHUNK_SIZE = 1024 * 1024; const MAX_UPLOAD_SIZE = 1024 * 1024 * 1024; // 1 GB export const MediaBox: FC<{ setMedia: (params: { id: string; path: string }[]) => void; standalone?: boolean; type?: 'image' | 'video'; closeModal: () => void; }> = ({ type, standalone, setMedia }) => { const [page, setPage] = useState(0); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebounce(search, 300); const fetch = useFetch(); const modals = useModals(); const toaster = useToaster(); useEffect(() => { setPage(0); }, [debouncedSearch]); const loadMedia = useCallback(async () => { const params = new URLSearchParams({ page: String(page + 1) }); if (debouncedSearch.trim()) { params.set('search', debouncedSearch.trim()); } return (await fetch(`/media?${params.toString()}`)).json(); }, [page, debouncedSearch]); const { data, mutate, isLoading } = useSWR( `get-media-${page}-${debouncedSearch}`, loadMedia ); const [selected, setSelected] = useState([]); const t = useT(); const uploaderRef = useRef(null); const mediaDirectory = useMediaDirectory(); const [loading, setLoading] = useState(false); const uppy = useUppyUploader({ allowedFileTypes: type == 'image' ? 'image/*' : type == 'video' ? 'video/mp4' : 'image/*,video/mp4', onUploadSuccess: async (arr) => { await mutate(); if (standalone) { return; } setSelected((prevSelected) => { return [...prevSelected, ...arr]; }); }, onStart: () => setLoading(true), onEnd: () => setLoading(false), }); const addRemoveSelected = useCallback( (media: any) => () => { if (standalone) { return; } const exists = selected.find((p: any) => p.id === media.id); if (exists) { setSelected(selected.filter((f: any) => f.id !== media.id)); return; } setSelected([...selected, media]); }, [selected] ); const addMedia = useCallback(async () => { if (standalone) { return; } // @ts-ignore setMedia(selected); modals.closeCurrent(); }, [selected]); const addToUpload = useCallback( async (e: ChangeEvent) => { const files = Array.from(e.target.files || []); const totalSize = files.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { toaster.show( t( 'upload_size_limit_exceeded', 'Upload size limit exceeded. Maximum 1 GB per upload session.' ), 'warning' ); return; } setLoading(true); // @ts-ignore uppy.addFiles(files); }, [toaster, t] ); const dragAndDrop = useCallback( async (event: ClipboardEvent | File[]) => { // @ts-ignore const clipboardItems = event.map((p) => ({ kind: 'file', getAsFile: () => p, })); if (!clipboardItems) { return; } const files: File[] = []; // @ts-ignore for (const item of clipboardItems) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } const totalSize = files.reduce((acc, file) => acc + file.size, 0); if (totalSize > MAX_UPLOAD_SIZE) { toaster.show( t( 'upload_size_limit_exceeded', 'Upload size limit exceeded. Maximum 1 GB per upload session.' ), 'warning' ); return; } setLoading(true); for (const file of files) { uppy.addFile(file); } }, [toaster, t] ); const maximize = useCallback( (media: Media) => async (e: any) => { e.stopPropagation(); modals.openModal({ title: '', top: 10, children: (
{hasExtension(media.path, 'mp4') ? ( ) : ( media )}
), }); }, [] ); const deleteImage = useCallback( (media: Media) => async (e: any) => { e.stopPropagation(); if ( !(await deleteDialog( t( 'are_you_sure_you_want_to_delete_the_image', 'Are you sure you want to delete the image?' ) )) ) { return; } await fetch(`/media/${media.id}`, { method: 'DELETE', }); mutate(); }, [mutate] ); const btn = useMemo(() => { return ( ); }, [t, loading]); return (
setSearch(e.target.value)} placeholder={t('search_media_by_name', 'Search by file name')} className="w-full h-[44px] px-[14px] rounded-[8px] bg-newBgColorInner border border-newColColor text-[14px] outline-none focus:border-[#612BD3]" />
{btn} mutate()} />
{!isLoading && !data?.results?.length && ( <>
{debouncedSearch ? t( 'no_media_match_search', 'No media matches your search' ) : t( 'you_dont_have_any_media_yet', "You don't have any media yet" )}
{t( 'select_or_upload_pictures_max_1gb', 'Select or upload pictures (maximum 1 GB per upload).' )}{' '} {'\n'} {t( 'you_can_drag_drop_pictures', 'You can also drag & drop pictures.' )}
{btn} mutate()} />
)} {isLoading && ( <> {[...new Array(16)].map((_, i) => (
))} )} {data?.results ?.filter((f: any) => { if (type === 'video') { return hasExtension(f.path, 'mp4'); } else if (type === 'image') { return !hasExtension(f.path, 'mp4'); } return true; }) .map((media: any) => (
p.id === media.id) ? 'border-[#612BD3]' : 'border-transparent' )} onClick={addRemoveSelected(media)} > {!!selected.find((p: any) => p.id === media.id) ? (
{selected.findIndex((z: any) => z.id === media.id) + 1}
) : ( )}
{media.originalName}
{hasExtension(media.path, 'mp4') ? ( ) : ( media )}
))}
{(data?.pages || 0) > 1 && ( )} {!standalone && (
{!isLoading && !!data?.results?.length && ( )}
)}
); }; export const MultiMediaComponent: FC<{ label: string; description: string; mediaNotAvailable?: boolean; dummy: boolean; allData: { content: string; id?: string; image?: Array<{ id: string; path: string; }>; }[]; value?: Array<{ path: string; id: string; }>; text: string; name: string; error?: any; onOpen?: () => void; onClose?: () => void; toolBar?: React.ReactNode; information?: React.ReactNode; onChange: (event: { target: { name: string; value?: Array<{ id: string; path: string; alt?: string; thumbnail?: string; thumbnailTimestamp?: number; }>; }; }) => void; }> = (props) => { const { name, error, text, onChange, value, allData, dummy, toolBar, information, mediaNotAvailable, } = props; const user = useUser(); const modals = useModals(); const t = useT(); useEffect(() => { if (value) { setCurrentMedia(value); } }, [value]); const [currentMedia, setCurrentMedia] = useState(value); const mediaDirectory = useMediaDirectory(); const changeMedia = useCallback( ( m: | { path: string; id: string; } | { path: string; id: string; }[] ) => { const mediaArray = Array.isArray(m) ? m : [m]; const newMedia = [...(currentMedia || []), ...mediaArray]; setCurrentMedia(newMedia); onChange({ target: { name, value: newMedia, }, }); }, [currentMedia] ); const showModal = useCallback(() => { modals.openModal({ title: t('media_library', 'Media Library'), askClose: false, closeOnEscape: true, fullScreen: true, size: 'calc(100% - 80px)', height: 'calc(100% - 80px)', children: (close) => ( ), }); }, [changeMedia, t]); const clearMedia = useCallback( (topIndex: number) => () => { const newMedia = currentMedia?.filter((f, index) => index !== topIndex); setCurrentMedia(newMedia); onChange({ target: { name, value: newMedia, }, }); }, [currentMedia] ); const designMedia = useCallback(() => { if (!!user?.tier?.ai && !dummy) { modals.openModal({ askClose: false, title: t('design_media', 'Design Media'), size: '80%', children: (close) => ( ), }); } }, [changeMedia, t]); return ( <>
{!!currentMedia && ( onChange({ target: { name: 'upload', value } }) } className="flex gap-[10px] sortable-container" animation={200} swap={true} handle=".dragging" > {currentMedia.map((media, index) => (
{ modals.openModal({ title: t('media_settings', 'Media Settings'), children: (close) => ( { onChange({ target: { name: 'upload', value: currentMedia.map((p) => { if (p.id === media.id) { return { ...p, ...value, }; } return p; }), }, }); }} /> ), }); }} className="absolute top-[50%] left-[50%] -translate-x-[50%] -translate-y-[50%] bg-black/80 rounded-[10px] opacity-0 group-hover:opacity-100 transition-opacity z-[9]" >
{hasExtension(media?.path, 'mp4') ? ( ) : ( )}
))}
)}
{!mediaNotAvailable && (
{t('insert_media', 'Insert Media')}
{t('design_media', 'Design Media')}
{!!user?.tier?.ai && ( <> )}
)} {!mediaNotAvailable && (
)} {!!toolBar && (
{toolBar}
)} {information && (
{information}
)}
{error}
); }; export const MediaComponent: FC<{ label: string; description: string; value?: { path: string; id: string; }; name: string; onChange: (event: { target: { name: string; value?: { id: string; path: string; }; }; }) => void; type?: 'image' | 'video'; width?: number; height?: number; }> = (props) => { const t = useT(); const { name, type, label, description, onChange, value, width, height } = props; const { getValues } = useSettings(); const user = useUser(); useEffect(() => { const settings = getValues()[props.name]; if (settings) { setCurrentMedia(settings); } }, []); const [currentMedia, setCurrentMedia] = useState(value); const modals = useModals(); const mediaDirectory = useMediaDirectory(); const showDesignModal = useCallback(() => { modals.openModal({ title: t('media_editor', 'Media Editor'), askClose: false, closeOnEscape: true, fullScreen: true, size: 'calc(100% - 80px)', height: 'calc(100% - 80px)', children: (close) => ( ), }); }, [t]); const changeMedia = useCallback((m: { path: string; id: string }[]) => { setCurrentMedia(m[0]); onChange({ target: { name, value: m[0], }, }); }, []); const showModal = useCallback(() => { modals.openModal({ title: t('media_library', 'Media Library'), askClose: false, closeOnEscape: true, fullScreen: true, size: 'calc(100% - 80px)', height: 'calc(100% - 80px)', children: (close) => ( ), }); }, [t]); const clearMedia = useCallback(() => { setCurrentMedia(undefined); onChange({ target: { name, value: undefined, }, }); }, [value]); return (
{label}
{description}
{!!currentMedia && (
window.open(mediaDirectory.set(currentMedia.path))} />
)}
); };