From 12e065b38b21e5ac8a9b3245268b5d76d8ff2b1f Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 1 Jul 2025 01:42:59 +0700 Subject: [PATCH] feat: thumbnails and alt --- .../src/api/routes/media.controller.ts | 27 +- .../launches/helpers/linkedin.component.tsx | 8 + .../helpers/media.settings.component.tsx | 565 ++++++++++++++++-- .../src/components/launches/polonto.tsx | 10 + .../src/components/media/media.component.tsx | 36 +- .../src/components/new-launch/editor.tsx | 34 +- .../components/new-launch/manage.modal.tsx | 8 +- .../src/components/new-launch/store.ts | 7 + .../third-parties/third-party.media.tsx | 10 + .../database/prisma/media/media.repository.ts | 27 +- .../database/prisma/media/media.service.ts | 5 + .../src/database/prisma/schema.prisma | 1 + .../dtos/media/save.media.information.dto.ts | 13 + 13 files changed, 683 insertions(+), 68 deletions(-) create mode 100644 libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts diff --git a/apps/backend/src/api/routes/media.controller.ts b/apps/backend/src/api/routes/media.controller.ts index b86bfe0d..b3284f44 100644 --- a/apps/backend/src/api/routes/media.controller.ts +++ b/apps/backend/src/api/routes/media.controller.ts @@ -22,6 +22,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { CustomFileValidationPipe } from '@gitroom/nestjs-libraries/upload/custom.upload.validation'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto'; @ApiTags('Media') @Controller('/media') @@ -95,16 +96,38 @@ export class MediaController { if (!name) { return false; } - return this._mediaService.saveFile(org.id, name, process.env.CLOUDFLARE_BUCKET_URL + '/' + name); + return this._mediaService.saveFile( + org.id, + name, + process.env.CLOUDFLARE_BUCKET_URL + '/' + name + ); + } + + @Post('/information') + saveMediaInformation( + @GetOrgFromRequest() org: Organization, + @Body() body: SaveMediaInformationDto + ) { + return this._mediaService.saveMediaInformation( + org.id, + body + ); } @Post('/upload-simple') @UseInterceptors(FileInterceptor('file')) async uploadSimple( @GetOrgFromRequest() org: Organization, - @UploadedFile('file') file: Express.Multer.File + @UploadedFile('file') file: Express.Multer.File, + @Body('preventSave') preventSave: string = 'false' ) { const getFile = await this.storage.uploadFile(file); + + if (preventSave === 'true') { + const { path } = getFile; + return { path }; + } + return this._mediaService.saveFile( org.id, getFile.originalname, diff --git a/apps/frontend/src/components/launches/helpers/linkedin.component.tsx b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx index 0dc5ecb8..e5a979d2 100644 --- a/apps/frontend/src/components/launches/helpers/linkedin.component.tsx +++ b/apps/frontend/src/components/launches/helpers/linkedin.component.tsx @@ -101,6 +101,13 @@ export const LinkedinCompany: FC<{ id: string; }> = (props) => { const { onClose, onSelect, id } = props; + const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton); + useEffect(() => { + setActivateExitButton(false); + return () => { + setActivateExitButton(true); + }; + }, []); const fetch = useFetch(); const [company, setCompany] = useState(null); const toast = useToaster(); @@ -136,6 +143,7 @@ export const LinkedinCompany: FC<{ + + + + {/* Hidden file input */} + + + {mode === 'frame' ? ( + <> +
+
+ + {isLoaded && ( + <> +
+ +
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ +
+ +
+ + )} + + ) : ( +
+
+ + + +
+

Upload a custom thumbnail image

+

+ PNG, JPG, JPEG up to 10MB +

+
+
+ + +
+ )} + + + + ); }; export const MediaComponentInner: FC<{ onClose: () => void; - onSelect: (tag: string) => void; + onSelect: (media: { + id: string; + name: string; + path: string; + thumbnail: string; + alt: string; + }) => void; media: - | { id: string; name: string; path: string; thumbnail: string } + | { id: string; name: string; path: string; thumbnail: string; alt: string } | undefined; }> = (props) => { const { onClose, onSelect, media } = props; + const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton); + const newFetch = useFetch(); + const [newThumbnail, setNewThumbnail] = useState(null); + const [isEditingThumbnail, setIsEditingThumbnail] = useState(false); + const [altText, setAltText] = useState(media?.alt || ''); + const [loading, setLoading] = useState(false); + const [thumbnail, setThumbnail] = useState(props.media?.thumbnail || null); + + useEffect(() => { + setActivateExitButton(false); + return () => { + setActivateExitButton(true); + }; + }, []); + + const save = useCallback(async () => { + setLoading(true); + let path = thumbnail || ''; + if (newThumbnail) { + const blob = await (await fetch(newThumbnail)).blob(); + const formData = new FormData(); + formData.append('file', blob, 'media.jpg'); + formData.append('preventSave', 'true'); + const data = await ( + await newFetch('/media/upload-simple', { + method: 'POST', + body: formData, + }) + ).json(); + path = data.path; + } + + const media = await ( + await newFetch('/media/information', { + method: 'POST', + body: JSON.stringify({ + id: props.media.id, + alt: altText, + thumbnail: path, + }), + }) + ).json(); + + onSelect(media); + onClose(); + }, [altText, newThumbnail, thumbnail]); return ( -
-
-
-
- -
- -
-
- {media?.path.indexOf('mp4') > -1 && } + + + + +
+
+
+ + setAltText(e.target.value)} + placeholder="Describe the image/video content..." + className="w-full px-3 py-2 bg-fifth border border-tableBorder rounded-lg text-textColor placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-forth focus:border-transparent" + /> +
+ {media?.path.indexOf('mp4') > -1 && ( + <> + {/* Alt Text Input */} +
+ {!isEditingThumbnail ? ( +
+ {/* Show existing thumbnail if it exists */} + {(newThumbnail || thumbnail) && ( +
+ + Current Thumbnail: + + Current thumbnail +
+ )} + + {/* Action Buttons */} +
+ + {(thumbnail || newThumbnail) && ( + + )} +
+
+ ) : ( +
+ {/* Back button */} +
+ +
+ + {/* Thumbnail Editor */} + { + // Convert blob to base64 or handle as needed + const reader = new FileReader(); + reader.onload = () => { + // You can handle the result here - for now just call onSelect with the blob URL + const url = URL.createObjectURL(blob); + setNewThumbnail(url); + setIsEditingThumbnail(false); + }; + reader.readAsDataURL(blob); + }} + media={media} + altText={altText} + onAltTextChange={setAltText} + /> +
+ )} +
+ + )} + + {!isEditingThumbnail && ( +
+ + +
+ )} +
diff --git a/apps/frontend/src/components/launches/polonto.tsx b/apps/frontend/src/components/launches/polonto.tsx index 89c5bd1e..ffd73225 100644 --- a/apps/frontend/src/components/launches/polonto.tsx +++ b/apps/frontend/src/components/launches/polonto.tsx @@ -21,6 +21,7 @@ import { PictureGeneratorSection } from '@gitroom/frontend/components/launches/p import { useUser } from '@gitroom/frontend/components/layout/user.context'; import { loadVars } from '@gitroom/react/helpers/variable.context'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; const store = createStore({ get key() { return loadVars().plontoKey; @@ -75,6 +76,15 @@ const Polonto: FC<{ height?: number; }> = (props) => { const { setMedia, type, closeModal } = props; + + const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton); + useEffect(() => { + setActivateExitButton(false); + return () => { + setActivateExitButton(true); + }; + }, []); + const user = useUser(); const features = useMemo(() => { return [ diff --git a/apps/frontend/src/components/media/media.component.tsx b/apps/frontend/src/components/media/media.component.tsx index 494d1970..bbc4ccbe 100644 --- a/apps/frontend/src/components/media/media.component.tsx +++ b/apps/frontend/src/components/media/media.component.tsx @@ -31,6 +31,7 @@ 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 { useMediaSettings } from '@gitroom/frontend/components/launches/helpers/media.settings.component'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; const Polonto = dynamic( () => import('@gitroom/frontend/components/launches/polonto') ); @@ -159,15 +160,25 @@ export const MediaBox: FC<{ }> = (props) => { const { setMedia, type, closeModal } = props; const [mediaList, setListMedia] = useState([]); + const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton); const fetch = useFetch(); const mediaDirectory = useMediaDirectory(); const [page, setPage] = useState(0); const [pages, setPages] = useState(0); const [selectedMedia, setSelectedMedia] = useState([]); const ref = useRef(null); + + useEffect(() => { + setActivateExitButton(false); + return () => { + setActivateExitButton(true); + }; + }, []); + const loadMedia = useCallback(async () => { return (await fetch(`/media?page=${page + 1}`)).json(); }, [page]); + const setNewMedia = useCallback( (media: Media) => () => { setSelectedMedia( @@ -185,13 +196,7 @@ export const MediaBox: FC<{ }, [selectedMedia] ); - 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]); @@ -481,6 +486,8 @@ export const MultiMediaComponent: FC<{ value?: Array<{ id: string; path: string; + alt?: string; + thumbnail?: string; }>; }; }) => void; @@ -610,7 +617,20 @@ export const MultiMediaComponent: FC<{
mediaSettings(media)} + onClick={async () => { + const data: any = await mediaSettings(media); + console.log( + value?.map((p) => (p.id === data.id ? data : p)) + ); + onChange({ + target: { + name: 'upload', + value: value?.map((p) => + p.id === data.id ? data : 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-[100]" >
+
+ {setImages && ( + { + setImages(value.target.value); + }} + onOpen={() => {}} + onClose={() => {}} + /> + )} +
-
- {setImages && ( - { - setImages(value.target.value); - }} - onOpen={() => {}} - onClose={() => {}} - /> - )} -
{(props?.totalChars || 0) > 0 && (
= (props) => { integrations, setSelectedIntegrations, locked, + activateExitButton, } = useLaunchStore( useShallow((state) => ({ hide: state.hide, @@ -73,6 +74,7 @@ export const ManageModal: FC = (props) => { integrations: state.integrations, setSelectedIntegrations: state.setSelectedIntegrations, locked: state.locked, + activateExitButton: state.activateExitButton })) ); @@ -96,6 +98,10 @@ export const ManageModal: FC = (props) => { }, [existingData, mutate, modal]); const askClose = useCallback(async () => { + if (!activateExitButton) { + return; + } + if ( await deleteDialog( t( @@ -111,7 +117,7 @@ export const ManageModal: FC = (props) => { } modal.closeAll(); } - }, []); + }, [activateExitButton]); const changeCustomer = useCallback( (customer: string) => { diff --git a/apps/frontend/src/components/new-launch/store.ts b/apps/frontend/src/components/new-launch/store.ts index d7b0b491..d00b22a7 100644 --- a/apps/frontend/src/components/new-launch/store.ts +++ b/apps/frontend/src/components/new-launch/store.ts @@ -30,6 +30,7 @@ interface StoreState { repeater?: number; isCreateSet: boolean; totalChars: number; + activateExitButton: boolean; tags: { label: string; value: string }[]; tab: 0 | 1; current: string; @@ -114,9 +115,11 @@ interface StoreState { media: { id: string; path: string }[] ) => void; setPostComment: (postComment: PostComment) => void; + setActivateExitButton?: (activateExitButton: boolean) => void; } const initialState = { + activateExitButton: true, date: dayjs(), postComment: PostComment.ALL, tags: [] as { label: string; value: string }[], @@ -498,4 +501,8 @@ export const useLaunchStore = create()((set) => ({ set((state) => ({ postComment, })), + setActivateExitButton: (activateExitButton: boolean) => + set((state) => ({ + activateExitButton, + })), })); diff --git a/apps/frontend/src/components/third-parties/third-party.media.tsx b/apps/frontend/src/components/third-parties/third-party.media.tsx index e7bfc6b8..f96bbda9 100644 --- a/apps/frontend/src/components/third-parties/third-party.media.tsx +++ b/apps/frontend/src/components/third-parties/third-party.media.tsx @@ -7,6 +7,7 @@ import React, { createContext, FC, useCallback, + useEffect, useMemo, useState, } from 'react'; @@ -15,6 +16,7 @@ import useSWR from 'swr'; import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import './providers/heygen.provider'; import { thirdPartyList } from '@gitroom/frontend/components/third-parties/third-party.wrapper'; +import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store'; const ThirdPartyContext = createContext({ id: '', @@ -57,6 +59,14 @@ export const ThirdPartyPopup: FC<{ const { closeModal, thirdParties, allData, onChange } = props; const [thirdParty, setThirdParty] = useState(null); + const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton); + useEffect(() => { + setActivateExitButton(false); + return () => { + setActivateExitButton(true); + }; + }, []); + const Component = useMemo(() => { if (!thirdParty) { return EmptyComponent; diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts index bf19bcd9..648dba43 100644 --- a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts @@ -1,12 +1,12 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; import { Injectable } from '@nestjs/common'; +import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto'; @Injectable() export class MediaRepository { constructor(private _media: PrismaRepository<'media'>) {} saveFile(org: string, fileName: string, filePath: string) { - const file = fileName.split('.'); return this._media.model.media.create({ data: { organization: { @@ -16,15 +16,13 @@ export class MediaRepository { }, name: fileName, path: filePath, - ...(fileName.indexOf('mp4') > -1 - ? { thumbnail: `${file[0]}.thumbnail.jpg` } - : {}), }, select: { id: true, name: true, path: true, thumbnail: true, + alt: true, }, }); } @@ -49,6 +47,26 @@ export class MediaRepository { }); } + saveMediaInformation(org: string, data: SaveMediaInformationDto) { + return this._media.model.media.update({ + where: { + id: data.id, + organizationId: org, + }, + data: { + alt: data.alt, + thumbnail: data.thumbnail, + }, + select: { + id: true, + name: true, + alt: true, + thumbnail: true, + path: true, + }, + }); + } + async getMedia(org: string, page: number) { const pageNum = (page || 1) - 1; const query = { @@ -75,6 +93,7 @@ export class MediaRepository { name: true, path: true, thumbnail: true, + alt: true, }, skip: pageNum * 28, take: 28, diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts index 921ebc11..496504f9 100644 --- a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts @@ -3,6 +3,7 @@ import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media 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'; +import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto'; @Injectable() export class MediaService { @@ -44,4 +45,8 @@ export class MediaService { getMedia(org: string, page: number) { return this._mediaRepository.getMedia(org, page); } + + saveMediaInformation(org: string, data: SaveMediaInformationDto) { + return this._mediaRepository.saveMediaInformation(org, data); + } } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index b09684ca..52f67514 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -203,6 +203,7 @@ model Media { organization Organization @relation(fields: [organizationId], references: [id]) organizationId String thumbnail String? + alt String? fileSize Int @default(0) type String @default("image") createdAt DateTime @default(now()) diff --git a/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts b/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts new file mode 100644 index 00000000..0cf4d58c --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator'; + +export class SaveMediaInformationDto { + @IsString() + id: string; + + @IsString() + alt: string; + + @IsUrl() + @ValidateIf((o) => !!o.thumbnail) + thumbnail: string; +}