postiz/apps/frontend/src/components/media/media.component.tsx

848 lines
27 KiB
TypeScript

'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 { 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 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 { Dashboard } from '@uppy/react';
import {
ChevronLeftIcon,
ChevronRightIcon,
PlusIcon,
DeleteCircleIcon,
CloseCircleIcon,
DragHandleIcon,
MediaSettingsIcon,
InsertMediaIcon,
DesignMediaIcon,
VerticalDividerIcon,
NoMediaIcon,
} from '@gitroom/frontend/components/ui/icons';
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 (
<ul className="flex flex-row items-center gap-1 justify-center mt-[15px]">
<li className={clsx(current === 0 && 'opacity-20 pointer-events-none')}>
<div
className="cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 h-10 px-4 py-2 gap-1 ps-2.5 text-gray-400 hover:text-white border-[#1F1F1F] hover:bg-forth"
aria-label="Go to previous page"
onClick={() => setPage(current - 1)}
>
<ChevronLeftIcon className="lucide lucide-chevron-left h-4 w-4" />
<span>{t('previous', 'Previous')}</span>
</div>
</li>
{paginationItems.map((item, index) => (
<li key={index}>
{item === '...' ? (
<span className="inline-flex items-center justify-center h-10 w-10 text-textColor select-none">
...
</span>
) : (
<div
aria-current="page"
onClick={() => setPage(item - 1)}
className={clsx(
'cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border hover:bg-forth h-10 w-10 hover:text-white border-newBorder',
current === item - 1
? 'bg-forth !text-white'
: 'text-textColor hover:text-white'
)}
>
{item}
</div>
)}
</li>
))}
<li
className={clsx(
current + 1 === totalPages && 'opacity-20 pointer-events-none'
)}
>
<a
className="text-textColor hover:text-white group cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 h-10 px-4 py-2 gap-1 pe-2.5 text-gray-400 border-[#1F1F1F] hover:bg-forth"
aria-label="Go to next page"
onClick={() => setPage(current + 1)}
>
<span>{t('next', 'Next')}</span>
<ChevronRightIcon className="lucide lucide-chevron-right h-4 w-4" />
</a>
</li>
</ul>
);
};
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 (
<div className="text-textColor">
<MediaBox setMedia={callBack!} closeModal={closeModal} />
</div>
);
};
export const showMediaBox = (
callback: (params: { id: string; path: string }) => void
) => {
showModalEmitter.emit('show-modal', callback);
};
const CHUNK_SIZE = 1024 * 1024;
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 fetch = useFetch();
const modals = useModals();
const loadMedia = useCallback(async () => {
return (await fetch(`/media?page=${page + 1}`)).json();
}, [page]);
const { data, mutate, isLoading } = useSWR(`get-media-${page}`, loadMedia);
const [selected, setSelected] = useState([]);
const t = useT();
const uploaderRef = useRef<any>(null);
const mediaDirectory = useMediaDirectory();
const uppy = useUppyUploader({
allowedFileTypes:
type == 'image'
? 'image/*'
: type == 'video'
? 'video/mp4'
: 'image/*,video/mp4',
onUploadSuccess: async (arr) => {
uppy.clear();
await mutate();
if (standalone) {
return;
}
setSelected((prevSelected) => {
return [...prevSelected, ...arr];
});
},
});
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<HTMLInputElement>) => {
const files = Array.from(e.target.files).slice(0, 5);
for (const file of files) {
uppy.addFile(file);
}
}, []);
const dragAndDrop = useCallback(
async (event: ClipboardEvent<HTMLDivElement> | File[]) => {
// @ts-ignore
const clipboardItems = event.map((p) => ({
kind: 'file',
getAsFile: () => p,
}));
if (!clipboardItems) {
return;
}
const files = [];
// @ts-ignore
for (const item of clipboardItems) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
for (const file of files.slice(0, 5)) {
uppy.addFile(file);
}
},
[]
);
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 (
<button
onClick={() => uploaderRef?.current?.click()}
className="cursor-pointer bg-btnSimple changeColor flex gap-[8px] h-[44px] px-[18px] justify-center items-center rounded-[8px]"
>
<PlusIcon size={14} />
<div>Upload</div>
</button>
);
}, []);
return (
<DropFiles className="flex flex-col flex-1" onDrop={dragAndDrop}>
<div className="flex flex-col flex-1">
<div
className={clsx(
'flex',
!isLoading && !data?.results?.length && 'hidden'
)}
>
{!isLoading && !!data?.results?.length && (
<div className="flex-1 text-[14px] font-[600] whitespace-pre-line">
Select or upload pictures (maximum 5 at a time).{'\n'}
You can also drag & drop pictures.
</div>
)}
<input
type="file"
ref={uploaderRef}
onChange={addToUpload}
className="hidden"
multiple={true}
/>
{!isLoading && !!data?.results?.length && btn}
</div>
<div className="w-full pointer-events-none relative mt-[5px] mb-[5px]">
<div className="w-full h-[46px] overflow-hidden absolute left-0 bg-newBgColorInner uppyChange">
<Dashboard
height={46}
uppy={uppy}
id={`uploader`}
showProgressDetails={true}
hideUploadButton={true}
hideRetryButton={true}
hidePauseResumeButton={true}
hideCancelButton={true}
hideProgressAfterFinish={true}
/>
</div>
<div className="w-full h-[46px] uppyChange" />
</div>
<div
className={clsx(
'flex-1 relative',
!isLoading &&
!data?.results?.length &&
'bg-newTextColor/[0.02] rounded-[12px]'
)}
>
<div
className={clsx(
'absolute -left-[3px] -top-[3px] withp3 h-full overflow-x-hidden overflow-y-auto scrollbar scrollbar-thumb-newColColor scrollbar-track-newBgColorInner',
!isLoading &&
!data?.results?.length &&
'flex justify-center items-center gap-[20px] flex-col'
)}
>
{!isLoading && !data?.results?.length && (
<>
<NoMediaIcon />
<div className="text-[20px] font-[600]">
You don't have any media yet
</div>
<div className="whitespace-pre-line text-newTextColor/[0.6] text-center">
Select or upload pictures (maximum 5 at a time). {'\n'}
You can also drag & drop pictures.
</div>
<div className="forceChange">{btn}</div>
</>
)}
{isLoading && (
<>
{[...new Array(16)].map((_, i) => (
<div
className={clsx(
'px-[3px] py-[3px] float-left rounded-[6px] cursor-pointer w8-max aspect-square'
)}
key={i}
>
<div className="w-full h-full bg-newSep rounded-[6px] animate-pulse" />
</div>
))}
</>
)}
{data?.results
?.filter((f: any) => {
if (type === 'video') {
return f.path.indexOf('mp4') > -1;
} else if (type === 'image') {
return f.path.indexOf('mp4') === -1;
}
return true;
})
.map((media: any) => (
<div
className={clsx(
'group px-[3px] py-[3px] float-left rounded-[6px] w8-max aspect-square',
!standalone && 'cursor-pointer'
)}
key={media.id}
>
<div
className={clsx(
'w-full h-full rounded-[6px] border-[4px] relative',
!!selected.find((p) => p.id === media.id)
? 'border-[#612BD3]'
: 'border-transparent'
)}
onClick={addRemoveSelected(media)}
>
{!!selected.find((p: any) => p.id === media.id) ? (
<div className="flex justify-center items-center text-[14px] font-[500] w-[24px] h-[24px] rounded-full bg-[#612BD3] absolute -bottom-[10px] -end-[10px]">
{selected.findIndex((z: any) => z.id === media.id) + 1}
</div>
) : (
<DeleteCircleIcon
className="cursor-pointer hidden z-[100] group-hover:block absolute -top-[5px] -end-[5px]"
onClick={deleteImage(media)}
/>
)}
<div className="w-full h-full rounded-[6px] overflow-hidden">
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
width="100%"
height="100%"
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
alt="media"
/>
)}
</div>
</div>
</div>
))}
</div>
</div>
{(data?.pages || 0) > 1 && (
<Pagination
current={page}
totalPages={data?.pages}
setPage={setPage}
/>
)}
{!standalone && (
<div className="flex justify-end mt-[32px] gap-[8px]">
<button
onClick={() => modals.closeCurrent()}
className="cursor-pointer h-[52px] px-[20px] items-center justify-center border border-newTextColor/10 flex rounded-[10px]"
>
Cancel
</button>
{!isLoading && !!data?.results?.length && (
<button
onClick={standalone ? () => {} : addMedia}
disabled={selected.length === 0}
className="cursor-pointer text-white disabled:opacity-80 disabled:cursor-not-allowed h-[52px] px-[20px] items-center justify-center bg-[#612BD3] flex rounded-[10px]"
>
{t('add_selected_media', 'Add selected media')}
</button>
)}
</div>
)}
</div>
</DropFiles>
);
};
export const MultiMediaComponent: FC<{
label: string;
description: string;
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,
} = props;
const user = useUser();
const modals = useModals();
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: 'Media Library',
askClose: false,
closeOnEscape: true,
fullScreen: true,
size: 'calc(100% - 80px)',
height: 'calc(100% - 80px)',
children: (close) => (
<MediaBox setMedia={changeMedia} closeModal={close} />
),
});
}, [changeMedia]);
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: 'Design Media',
size: '80%',
children: (close) => (
<Polonto setMedia={changeMedia} closeModal={close} />
),
});
}
}, [changeMedia]);
const t = useT();
return (
<>
<div className="b1 flex flex-col gap-[8px] rounded-bl-[8px] select-none w-full">
<div className="flex gap-[10px] px-[12px]">
{!!currentMedia && (
<ReactSortable
list={currentMedia}
setList={(value) =>
onChange({ target: { name: 'upload', value } })
}
className="flex gap-[10px] sortable-container"
animation={200}
swap={true}
handle=".dragging"
>
{currentMedia.map((media, index) => (
<Fragment key={media.id}>
<div className="cursor-pointer rounded-[5px] w-[40px] h-[40px] border-2 border-tableBorder relative flex transition-all">
<DragHandleIcon className="z-[20] dragging absolute pe-[1px] pb-[3px] -start-[4px] -top-[4px] cursor-move" />
<div className="w-full h-full relative group">
<div
onClick={async () => {
modals.openModal({
title: 'Media Settings',
children: (close) => (
<MediaComponentInner
media={media as any}
onClose={close}
onSelect={(value: any) => {
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]"
>
<MediaSettingsIcon className="cursor-pointer relative z-[200]" />
</div>
{media?.path?.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media?.path)} />
) : (
<img
className="w-full h-full object-cover rounded-[4px]"
src={mediaDirectory.set(media?.path)}
/>
)}
</div>
<CloseCircleIcon
onClick={clearMedia(index)}
className="absolute -end-[4px] -top-[4px] z-[20] rounded-full bg-white"
/>
</div>
</Fragment>
))}
</ReactSortable>
)}
</div>
{!dummy && (
<div className="flex gap-[8px] px-[12px] border-t border-newColColor w-full b1 text-textColor">
<div className="flex py-[10px] b2 items-center gap-[4px]">
<div
onClick={showModal}
className="cursor-pointer h-[30px] rounded-[6px] justify-center items-center flex bg-newColColor px-[8px]"
>
<div className="flex gap-[8px] items-center">
<div>
<InsertMediaIcon />
</div>
<div className="text-[10px] font-[600] maxMedia:hidden block">
{t('insert_media', 'Insert Media')}
</div>
</div>
</div>
<div
onClick={designMedia}
className="cursor-pointer h-[30px] rounded-[6px] justify-center items-center flex bg-newColColor px-[8px]"
>
<div className="flex gap-[5px] items-center">
<div>
<DesignMediaIcon />
</div>
<div className="text-[10px] font-[600] iconBreak:hidden block">
{t('design_media', 'Design Media')}
</div>
</div>
</div>
<ThirdPartyMedia allData={allData} onChange={changeMedia} />
{!!user?.tier?.ai && (
<>
<AiImage value={text} onChange={changeMedia} />
<AiVideo value={text} onChange={changeMedia} />
</>
)}
</div>
<div className="text-newColColor h-full flex items-center">
<VerticalDividerIcon />
</div>
{!!toolBar && (
<div className="flex py-[10px] b2 items-center gap-[4px]">
{toolBar}
</div>
)}
{information && (
<div className="flex-1 justify-end flex py-[10px] b2 items-center gap-[4px]">
{information}
</div>
)}
</div>
)}
</div>
<div className="text-[12px] text-red-400">{error}</div>
</>
);
};
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 [modal, setShowModal] = useState(false);
const [mediaModal, setMediaModal] = useState(false);
const [currentMedia, setCurrentMedia] = useState(value);
const mediaDirectory = useMediaDirectory();
const closeDesignModal = useCallback(() => {
setMediaModal(false);
}, [modal]);
const showDesignModal = useCallback(() => {
setMediaModal(true);
}, [modal]);
const changeMedia = useCallback((m: { path: string; id: string }[]) => {
setCurrentMedia(m[0]);
onChange({
target: {
name,
value: m[0],
},
});
}, []);
const showModal = useCallback(() => {
setShowModal(!modal);
}, [modal]);
const clearMedia = useCallback(() => {
setCurrentMedia(undefined);
onChange({
target: {
name,
value: undefined,
},
});
}, [value]);
return (
<div className="flex flex-col gap-[8px]">
{modal && (
<MediaBox setMedia={changeMedia} closeModal={showModal} type={type} />
)}
{mediaModal && !!user?.tier?.ai && (
<Polonto
width={width}
height={height}
setMedia={changeMedia}
closeModal={closeDesignModal}
/>
)}
<div className="text-[14px]">{label}</div>
<div className="text-[12px]">{description}</div>
{!!currentMedia && (
<div className="my-[20px] cursor-pointer w-[200px] h-[200px] border-2 border-tableBorder">
<img
className="w-full h-full object-cover"
src={currentMedia.path}
onClick={() => window.open(mediaDirectory.set(currentMedia.path))}
/>
</div>
)}
<div className="flex gap-[5px]">
<Button onClick={showModal}>{t('select', 'Select')}</Button>
<Button onClick={showDesignModal} className="!bg-customColor45">
{t('editor', 'Editor')}
</Button>
<Button secondary={true} onClick={clearMedia}>
{t('clear', 'Clear')}
</Button>
</div>
</div>
);
};