feat: media uploader

This commit is contained in:
Nevo David 2025-01-06 02:08:32 +07:00
parent f4a4d8a209
commit 3bf4c1bc2a
6 changed files with 415 additions and 159 deletions

View File

@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
@ -31,6 +32,10 @@ export class MediaController {
private _subscriptionService: SubscriptionService
) {}
@Delete('/:id')
deleteMedia(@GetOrgFromRequest() org: Organization, @Param('id') id: string) {
return this._mediaService.deleteMedia(org.id, id);
}
@Post('/generate-image')
async generateImage(
@GetOrgFromRequest() org: Organization,
@ -96,15 +101,11 @@ export class MediaController {
}
@Post('/:endpoint')
// @UseInterceptors(FileInterceptor('file'))
// @UsePipes(new CustomFileValidationPipe())
async uploadFile(
@GetOrgFromRequest() org: Organization,
@Req() req: Request,
@Res() res: Response,
@Param('endpoint') endpoint: string
// @UploadedFile('file')
// file: Express.Multer.File
) {
const upload = await handleR2Upload(endpoint, req, res);
if (endpoint !== 'complete-multipart-upload') {
@ -122,11 +123,6 @@ export class MediaController {
);
res.status(200).json({ ...upload, saved: saveFile });
// const filePath =
// file.path.indexOf('http') === 0
// ? file.path
// : file.path.replace(process.env.UPLOAD_DIRECTORY, '');
// return this._mediaService.saveFile(org.id, file.originalname, filePath);
}
@Get('/')

View File

@ -1,6 +1,14 @@
'use client';
import { FC, useCallback, useEffect, useState } from 'react';
import {
ClipboardEvent,
FC,
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';
@ -16,11 +24,95 @@ import { MultipartFileUploader } from '@gitroom/frontend/components/media/new.up
import dynamic from 'next/dynamic';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { AiImage } from '@gitroom/frontend/components/launches/ai.image';
import Image from 'next/image';
import { DropFiles } from '@gitroom/frontend/components/layout/drop.files';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
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 { current, totalPages, setPage } = props;
const totalPagesList = useMemo(() => {
return Array.from({ length: totalPages }, (_, i) => i);
}, [totalPages]);
return (
<ul className="flex flex-row items-center gap-1">
<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 pl-2.5 text-gray-400 hover:text-white border-[#1F1F1F] hover:bg-forth"
aria-label="Go to previous page"
onClick={() => setPage(current - 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-left h-4 w-4"
>
<path d="m15 18-6-6 6-6" />
</svg>
<span>Previous</span>
</div>
</li>
{totalPagesList.map((page) => (
<li key={page} className="">
<div
aria-current="page"
onClick={() => setPage(page)}
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-[#1F1F1F] text-white',
current === page && 'bg-forth'
)}
>
{page + 1}
</div>
</li>
))}
<li
className={clsx(
current + 1 === totalPages && 'opacity-20 pointer-events-none'
)}
>
<a
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 pr-2.5 text-gray-400 hover:text-white border-[#1F1F1F] hover:bg-forth"
aria-label="Go to next page"
onClick={() => setPage(current + 1)}
>
<span>Next</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-right h-4 w-4"
>
<path d="m9 18 6-6-6-6" />
</svg>
</a>
</li>
</ul>
);
};
export const ShowMediaBoxModal: FC = () => {
const [showModal, setShowModal] = useState(false);
const [callBack, setCallBack] =
@ -63,31 +155,132 @@ export const MediaBox: FC<{
closeModal: () => void;
}> = (props) => {
const { setMedia, type, closeModal } = props;
const [pages, setPages] = useState(0);
const [mediaList, setListMedia] = useState<Media[]>([]);
const fetch = useFetch();
const mediaDirectory = useMediaDirectory();
const [page, setPage] = useState(0);
const [pages, setPages] = useState(0);
const [loading, setLoading] = useState(false);
const [selectedMedia, setSelectedMedia] = useState<Media[]>([]);
const ref = useRef<any>(null);
const loadMedia = useCallback(async () => {
return (await fetch('/media')).json();
}, []);
return (await fetch(`/media?page=${page + 1}`)).json();
}, [page]);
const setNewMedia = useCallback(
(media: Media) => () => {
setMedia(media);
closeModal();
setSelectedMedia(
selectedMedia.find((p) => p.id === media.id)
? selectedMedia.filter((f) => f.id !== media.id)
: [...selectedMedia.map((p) => ({ ...p })), { ...media }]
);
// closeModal();
},
[]
[selectedMedia]
);
const { data, mutate } = useSWR('get-media', loadMedia);
const removeMedia = useCallback(
(media: Media) => () => {
setSelectedMedia(selectedMedia.filter((f) => f.id !== media.id));
setListMedia(mediaList.filter((f) => f.id !== media.id));
},
[selectedMedia]
);
const addNewMedia = useCallback(
(media: Media[]) => () => {
setSelectedMedia((currentMedia) => [...currentMedia, ...media]);
// closeModal();
},
[selectedMedia]
);
const addMedia = useCallback(async () => {
// @ts-ignore
setMedia(selectedMedia);
closeModal();
}, [selectedMedia]);
const { data, mutate } = useSWR(`get-media-${page}`, loadMedia);
const finishUpload = useCallback(async () => {
const lastMedia = mediaList?.[0]?.id;
const newData = await mutate();
setNewMedia(newData.results[0])();
}, [mutate, setNewMedia]);
const untilLastMedia = newData.results.findIndex(
(f: any) => f.id === lastMedia
);
const onlyNewMedia = newData.results.slice(
0,
untilLastMedia === -1 ? newData.results.length : untilLastMedia
);
addNewMedia(onlyNewMedia)();
}, [mutate, addNewMedia, mediaList, selectedMedia]);
const dragAndDrop = useCallback(
async (event: ClipboardEvent<HTMLDivElement> | 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) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (isImage || isVideo) {
files.push(file); // Collect images or videos
}
}
}
}
if (files.length === 0) {
return;
}
ref.current.setOptions({
autoProceed: false,
});
for (const file of files) {
ref.current.addFile(file);
await ref.current.upload();
ref.current.clear();
}
ref.current.setOptions({
autoProceed: true,
});
finishUpload();
},
[mutate, addNewMedia, mediaList, selectedMedia]
);
const removeItem = useCallback(
(media: Media) => async (e: any) => {
e.stopPropagation();
if (!(await deleteDialog('Are you sure you want to delete the image?'))) {
return;
}
await fetch(`/media/${media.id}`, {
method: 'DELETE',
});
mutate();
},
[mutate]
);
useEffect(() => {
if (data?.pages) {
@ -100,110 +293,144 @@ export const MediaBox: FC<{
return (
<div className="fixed left-0 top-0 bg-primary/80 z-[300] w-full min-h-full p-4 md:p-[60px] animate-fade">
<div className="max-w-[1000px] w-full h-full bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative mx-auto">
<div className="flex flex-col">
<div className="flex-1">
<TopTitle title="Media Library" />
</div>
<button
onClick={closeModal}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
{!!mediaList.length && (
<button
className="flex absolute right-[40px] top-[7px] pointer hover:bg-third rounded-lg transition-all group px-2.5 py-2.5 text-sm font-semibold bg-transparent text-gray-800 hover:bg-gray-100 focus:text-primary-500"
type="button"
>
<div className="relative flex gap-2 items-center justify-center">
<MultipartFileUploader
onUploadSuccess={finishUpload}
allowedFileTypes={
type === 'video'
? 'video/mp4'
: type === 'image'
? 'image/*'
: 'image/*,video/mp4'
}
/>
<div className="max-w-[1000px] w-full h-full bg-sixth border-tableBorder border-2 rounded-xl relative mx-auto">
<DropFiles onDrop={dragAndDrop}>
<div className="pb-[20px] px-[20px] w-full h-full">
<div className="flex flex-col">
<div className="flex-1">
<TopTitle title="Media Library" />
</div>
</button>
)}
</div>
<div
className={clsx(
'flex flex-wrap gap-[10px] mt-[35px] pt-[20px]',
!!mediaList.length && 'justify-center items-center text-textColor'
)}
>
{!mediaList.length && (
<div className="flex flex-col text-center items-center justify-center mx-auto">
<div>You don{"'"}t have any assets yet.</div>
<div>Click the button below to upload one</div>
<div className="mt-[10px] justify-center items-center flex flex-col-reverse gap-[10px]">
<MultipartFileUploader
onUploadSuccess={finishUpload}
allowedFileTypes={
type === 'video'
? 'video/mp4'
: type === 'image'
? 'image/*'
: 'image/*,video/mp4'
}
/>
</div>
</div>
)}
{mediaList
.filter((f) => {
if (type === 'video') {
return f.path.indexOf('mp4') > -1;
} else if (type === 'image') {
return f.path.indexOf('mp4') === -1;
}
return true;
})
.map((media) => (
<div
key={media.id}
className="w-[120px] h-[120px] flex border-tableBorder border-2 cursor-pointer"
onClick={setNewMedia(media)}
<button
onClick={closeModal}
className="outline-none z-[300] absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<img
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
alt="media"
/>
)}
</div>
))}
{loading && (
<div className="w-[200px] h-[200px] flex border-tableBorder border-2 cursor-pointer relative">
<div className="absolute left-0 top-0 w-full h-full -mt-[50px] flex justify-center items-center">
<LoadingComponent />
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<div className="absolute flex justify-center mt-[55px] items-center pointer-events-none text-center h-[57px] w-full left-0 rounded-lg transition-all group text-sm font-semibold bg-transparent text-gray-800 hover:bg-gray-100 focus:text-primary-500">
Select or upload pictures (maximum 5 at a time)
<br />
You can also drag & drop pictures
</div>
{!!mediaList.length && (
<>
<div className="flex absolute h-[57px] w-full left-0 top-0 rounded-lg transition-all group text-sm font-semibold bg-transparent text-gray-800 hover:bg-gray-100 focus:text-primary-500">
<div className="relative flex flex-1 pr-[45px] gap-2 items-center justify-center">
<div className="flex-1" />
<MultipartFileUploader
uppRef={ref}
onUploadSuccess={finishUpload}
allowedFileTypes={
type === 'video'
? 'video/mp4'
: type === 'image'
? 'image/*'
: 'image/*,video/mp4'
}
/>
</div>
</div>
</>
)}
</div>
)}
</div>
<div
className={clsx(
'flex flex-wrap gap-[10px] mt-[35px] pt-[20px]',
!!mediaList.length &&
'justify-center items-center text-textColor'
)}
>
{!mediaList.length ? (
<div className="flex flex-col text-center items-center justify-center mx-auto">
<div>You don{"'"}t have any assets yet.</div>
<div>Click the button below to upload one</div>
<div className="mt-[10px] justify-center items-center flex flex-col-reverse gap-[10px]">
<MultipartFileUploader
onUploadSuccess={finishUpload}
allowedFileTypes={
type === 'video'
? 'video/mp4'
: type === 'image'
? 'image/*'
: 'image/*,video/mp4'
}
/>
</div>
</div>
) : (
<>
{selectedMedia.length > 0 && (
<div className="flex justify-center absolute top-[7px]">
<Button onClick={addMedia}>Add selected media</Button>
</div>
)}
</>
)}
{mediaList
.filter((f) => {
if (type === 'video') {
return f.path.indexOf('mp4') > -1;
} else if (type === 'image') {
return f.path.indexOf('mp4') === -1;
}
return true;
})
.map((media) => (
<div
key={media.id}
className={clsx(
'w-[120px] h-[120px] flex select-none relative cursor-pointer',
selectedMedia.find((p) => p.id === media.id)
? 'border-4 border-forth'
: 'border-tableBorder border-2'
)}
onClick={setNewMedia(media)}
>
<div
onClick={removeItem(media)}
className="border border-red-400 text-white flex justify-center items-center absolute w-[20px] h-[20px] rounded-full bg-red-700 -top-[5px] -right-[5px]"
>
X
</div>
{media.path.indexOf('mp4') > -1 ? (
<VideoFrame url={mediaDirectory.set(media.path)} />
) : (
<Image
width={120}
height={120}
className="w-full h-full object-cover"
src={mediaDirectory.set(media.path)}
alt="media"
/>
)}
</div>
))}
{(pages || 0) > 1 && (
<Pagination
current={page}
totalPages={pages}
setPage={setPage}
/>
)}
</div>
</div>
</DropFiles>
</div>
</div>
);
@ -236,8 +463,9 @@ export const MultiMediaComponent: FC<{
const mediaDirectory = useMediaDirectory();
const changeMedia = useCallback(
(m: { path: string; id: string }) => {
const newMedia = [...(currentMedia || []), m];
(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 } });
},
@ -267,7 +495,7 @@ export const MultiMediaComponent: FC<{
return (
<>
<div className="flex flex-col gap-[8px] bg-input rounded-bl-[8px]">
<div className="flex flex-col gap-[8px] bg-input rounded-bl-[8px] select-none">
{modal && <MediaBox setMedia={changeMedia} closeModal={showModal} />}
{mediaModal && !!user?.tier?.ai && (
<Polonto setMedia={changeMedia} closeModal={closeDesignModal} />
@ -326,10 +554,7 @@ export const MultiMediaComponent: FC<{
</Button>
{!!user?.tier?.ai && (
<AiImage
value={text}
onChange={changeMedia}
/>
<AiImage value={text} onChange={changeMedia} />
)}
</div>
@ -338,6 +563,7 @@ export const MultiMediaComponent: FC<{
<>
<div className="cursor-pointer w-[40px] h-[40px] border-2 border-tableBorder relative flex">
<div
className="w-full h-full"
onClick={() => window.open(mediaDirectory.set(media.path))}
>
{media.path.indexOf('mp4') > -1 ? (

View File

@ -15,10 +15,12 @@ import Compressor from '@uppy/compressor';
export function MultipartFileUploader({
onUploadSuccess,
allowedFileTypes,
uppRef,
}: {
// @ts-ignore
onUploadSuccess: (result: UploadResult) => void;
allowedFileTypes: string;
uppRef?: any;
}) {
const [loaded, setLoaded] = useState(false);
const [reload, setReload] = useState(false);
@ -49,6 +51,7 @@ export function MultipartFileUploader({
return (
<MultipartFileUploaderAfter
uppRef={uppRef || {}}
onUploadSuccess={onUploadSuccessExtended}
allowedFileTypes={allowedFileTypes}
/>
@ -68,7 +71,7 @@ export function useUppyUploader(props: {
const uppy2 = new Uppy({
autoProceed: true,
restrictions: {
maxNumberOfFiles: 1,
maxNumberOfFiles: 5,
allowedFileTypes: allowedFileTypes.split(','),
maxFileSize: 1000000000,
},
@ -117,19 +120,27 @@ export function useUppyUploader(props: {
export function MultipartFileUploaderAfter({
onUploadSuccess,
allowedFileTypes,
uppRef,
}: {
// @ts-ignore
onUploadSuccess: (result: UploadResult) => void;
allowedFileTypes: string;
uppRef: any;
}) {
const uppy = useUppyUploader({ onUploadSuccess, allowedFileTypes });
const uppyInstance = useMemo(() => {
uppRef.current = uppy;
return uppy;
}, []);
return (
<>
{/* <Dashboard uppy={uppy} /> */}
<ProgressBar uppy={uppy} />
<div className="pointer-events-none">
<ProgressBar uppy={uppyInstance} />
</div>
<FileInput
uppy={uppy}
uppy={uppyInstance}
locale={{
strings: {
chooseFiles: 'Upload',

View File

@ -19,6 +19,18 @@ export class MediaRepository {
});
}
deleteMedia(org: string, id: string) {
return this._media.model.media.update({
where: {
id,
organizationId: org,
},
data: {
deletedAt: new Date(),
}
});
}
async getMedia(org: string, page: number) {
const pageNum = (page || 1) - 1;
const query = {
@ -30,19 +42,18 @@ export class MediaRepository {
};
const pages =
pageNum === 0
? Math.ceil((await this._media.model.media.count(query)) / 10)
? Math.ceil((await this._media.model.media.count(query)) / 28)
: 0;
const results = await this._media.model.media.findMany({
where: {
organization: {
id: org,
},
organizationId: org,
deletedAt: null,
},
orderBy: {
createdAt: 'desc',
}
// skip: pageNum * 10,
// take: 10,
},
skip: pageNum * 28,
take: 28,
});
return {

View File

@ -1,32 +1,43 @@
import {Injectable} from "@nestjs/common";
import {MediaRepository} from "@gitroom/nestjs-libraries/database/prisma/media/media.repository";
import { Injectable } from '@nestjs/common';
import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media/media.repository';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { Organization } from '@prisma/client';
@Injectable()
export class MediaService {
constructor(
private _mediaRepository: MediaRepository,
private _openAi: OpenaiService,
private _subscriptionService: SubscriptionService
){}
constructor(
private _mediaRepository: MediaRepository,
private _openAi: OpenaiService,
private _subscriptionService: SubscriptionService
) {}
async generateImage(prompt: string, org: Organization, generatePromptFirst?: boolean) {
if (generatePromptFirst) {
prompt = await this._openAi.generatePromptForPicture(prompt);
console.log('Prompt:', prompt);
}
const image = await this._openAi.generateImage(prompt, !!generatePromptFirst);
await this._subscriptionService.useCredit(org);
return image;
}
async deleteMedia(org: string, id: string) {
return this._mediaRepository.deleteMedia(org, id);
}
saveFile(org: string, fileName: string, filePath: string) {
return this._mediaRepository.saveFile(org, fileName, filePath);
async generateImage(
prompt: string,
org: Organization,
generatePromptFirst?: boolean
) {
if (generatePromptFirst) {
prompt = await this._openAi.generatePromptForPicture(prompt);
console.log('Prompt:', prompt);
}
const image = await this._openAi.generateImage(
prompt,
!!generatePromptFirst
);
await this._subscriptionService.useCredit(org);
return image;
}
getMedia(org: string, page: number) {
return this._mediaRepository.getMedia(org, page);
}
}
saveFile(org: string, fileName: string, filePath: string) {
return this._mediaRepository.saveFile(org, fileName, filePath);
}
getMedia(org: string, page: number) {
return this._mediaRepository.getMedia(org, page);
}
}

View File

@ -173,6 +173,7 @@ model Media {
updatedAt DateTime @updatedAt
userPicture User[]
agencies SocialMediaAgency[]
deletedAt DateTime?
@@index([organizationId])
}