feat: thumbnails and alt

This commit is contained in:
Nevo David 2025-07-01 01:42:59 +07:00
parent 382df926fa
commit 12e065b38b
13 changed files with 683 additions and 68 deletions

View File

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

View File

@ -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<any>(null);
const toast = useToaster();
@ -136,6 +143,7 @@ export const LinkedinCompany: FC<{
<TopTitle title={'Select Company'} />
</div>
<button
onClick={onClose}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>

View File

@ -1,23 +1,46 @@
'use client';
import { EventEmitter } from 'events';
import React, { FC, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
const postUrlEmitter = new EventEmitter();
export const MediaSettingsLayout = () => {
const [showPostSelector, setShowPostSelector] = useState(false);
const [media, setMedia] = useState(undefined);
const [callback, setCallback] = useState<{
callback: (tag: string) => void;
callback: (tag: {
id: string;
name: string;
path: string;
thumbnail: string;
alt: string;
}) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-function
} | null>({
callback: (tag: string) => {},
callback: (params: {
id: string;
name: string;
path: string;
thumbnail: string;
alt: string;
}) => {},
} as any);
useEffect(() => {
postUrlEmitter.on(
'show',
(params: { media: any; callback: (url: string) => void }) => {
(params: {
media: any;
callback: (url: {
id: string;
name: string;
path: string;
thumbnail: string;
alt: string;
}) => void;
}) => {
setCallback(params);
setMedia(params.media);
setShowPostSelector(true);
@ -61,52 +84,522 @@ export const useMediaSettings = () => {
};
export const CreateThumbnail: FC<{
onSelect: (blob: Blob) => void;
media:
| { id: string; name: string; path: string; thumbnail: string }
| {
id: string;
name: string;
path: string;
thumbnail?: string;
alt?: string;
}
| undefined;
altText?: string;
onAltTextChange?: (altText: string) => void;
}> = (props) => {
return null;
const { onSelect, media, altText, onAltTextChange } = props;
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isLoaded, setIsLoaded] = useState(false);
const [isCapturing, setIsCapturing] = useState(false);
const [mode, setMode] = useState<'frame' | 'upload'>('frame');
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) {
setDuration(videoRef.current.duration);
setIsLoaded(true);
}
}, []);
const handleTimeUpdate = useCallback(() => {
if (videoRef.current) {
setCurrentTime(videoRef.current.currentTime);
}
}, []);
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const time = parseFloat(e.target.value);
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
}, []);
const captureFrame = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
setIsCapturing(true);
try {
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) {
setIsCapturing(false);
return;
}
// Set canvas dimensions to match video
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw current frame to canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Convert canvas to blob
canvas.toBlob(
(blob: Blob | null) => {
if (blob) {
onSelect(blob);
}
setIsCapturing(false);
},
'image/jpeg',
0.8
);
} catch (error) {
console.error('Error capturing frame:', error);
setIsCapturing(false);
// Fallback: try to capture using a different approach
try {
const video = videoRef.current;
if (video) {
// Create a temporary canvas element
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
tempCanvas.width = video.videoWidth;
tempCanvas.height = video.videoHeight;
tempCtx.drawImage(video, 0, 0);
tempCanvas.toBlob(
(blob: Blob | null) => {
if (blob) {
onSelect(blob);
}
setIsCapturing(false);
},
'image/jpeg',
0.8
);
}
}
} catch (fallbackError) {
console.error('Fallback capture also failed:', fallbackError);
alert(
'Unable to capture frame. This might be due to CORS restrictions on the video source.'
);
setIsCapturing(false);
}
}
}, [onSelect]);
const formatTime = useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}, []);
const handleFileUpload = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && file.type.startsWith('image/')) {
onSelect(file);
}
},
[onSelect]
);
const triggerFileUpload = useCallback(() => {
fileInputRef.current?.click();
}, []);
if (!media) return null;
return (
<div className="flex flex-col space-y-4">
{/* Mode Toggle */}
<div className="flex rounded-lg bg-fifth p-1">
<button
onClick={() => setMode('frame')}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
mode === 'frame'
? 'bg-forth text-white'
: 'text-textColor hover:text-white'
}`}
>
Select Frame
</button>
<button
onClick={() => setMode('upload')}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
mode === 'upload'
? 'bg-forth text-white'
: 'text-textColor hover:text-white'
}`}
>
Upload Image
</button>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{mode === 'frame' ? (
<>
<div className="relative bg-black rounded-lg overflow-hidden">
<video
ref={videoRef}
src={media.path}
className="w-full h-[200px] object-contain"
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
muted
preload="metadata"
crossOrigin="anonymous"
/>
<canvas ref={canvasRef} className="hidden" />
</div>
{isLoaded && (
<>
<div className="flex flex-col space-y-2">
<input
type="range"
min="0"
max={duration}
step="0.1"
value={currentTime}
onChange={handleSeek}
className="w-full h-2 bg-fifth rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #4f46e5 0%, #4f46e5 ${
(currentTime / duration) * 100
}%, #374151 ${
(currentTime / duration) * 100
}%, #374151 100%)`,
}}
/>
<div className="flex justify-between text-sm text-textColor">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex justify-center">
<button
onClick={captureFrame}
disabled={isCapturing}
className="bg-forth text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCapturing ? 'Capturing...' : 'Select This Frame'}
</button>
</div>
</>
)}
</>
) : (
<div className="flex flex-col items-center space-y-4 py-8">
<div className="text-center space-y-2">
<svg
className="mx-auto h-12 w-12 text-textColor"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="text-textColor">
<p className="text-sm">Upload a custom thumbnail image</p>
<p className="text-xs text-gray-400 mt-1">
PNG, JPG, JPEG up to 10MB
</p>
</div>
</div>
<button
onClick={triggerFileUpload}
className="bg-forth text-white px-6 py-3 rounded-lg hover:bg-opacity-80 transition-all flex items-center space-x-2"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15M17 8L12 3M12 3L7 8M12 3V15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Choose Image</span>
</button>
</div>
)}
<style jsx>{`
.slider::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #4f46e5;
cursor: pointer;
border: 2px solid #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
`}</style>
</div>
);
};
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<string | null>(null);
const [isEditingThumbnail, setIsEditingThumbnail] = useState(false);
const [altText, setAltText] = useState<string>(media?.alt || '');
const [loading, setLoading] = useState(false);
const [thumbnail, setThumbnail] = useState<string | null>(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 (
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex">
<div className="flex flex-col w-[500px] h-[250px] bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] relative">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Media Setting'} />
</div>
<button
onClick={onClose}
className="outline-none absolute end-[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"
<div className="text-textColor fixed start-0 top-0 bg-primary/80 z-[300] w-full h-full p-[60px] animate-fade justify-center flex bg-black/40">
<div className="w-full h-full relative">
<div className="w-[500px] bg-sixth border-tableBorder border-2 rounded-xl pb-[20px] px-[20px] absolute left-[50%] top-[100px] -translate-x-[50%]">
<div className="flex">
<div className="flex-1">
<TopTitle title={'Media Setting'} />
</div>
<button
onClick={onClose}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root bg-primary hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<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>
<div className="mt-[10px]">
{media?.path.indexOf('mp4') > -1 && <CreateThumbnail media={media} />}
<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>
<div className="mt-[10px] flex flex-col gap-[20px]">
<div className="flex flex-col space-y-2">
<label className="text-sm text-textColor font-medium">
Alt Text (for accessibility)
</label>
<input
type="text"
value={altText}
onChange={(e) => 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"
/>
</div>
{media?.path.indexOf('mp4') > -1 && (
<>
{/* Alt Text Input */}
<div>
{!isEditingThumbnail ? (
<div className="flex flex-col">
{/* Show existing thumbnail if it exists */}
{(newThumbnail || thumbnail) && (
<div className="flex flex-col space-y-2">
<span className="text-sm text-textColor">
Current Thumbnail:
</span>
<img
src={newThumbnail || thumbnail}
alt="Current thumbnail"
className="max-w-full max-h-[500px] object-contain rounded-lg border border-tableBorder"
/>
</div>
)}
{/* Action Buttons */}
<div className="flex space-x-2">
<button
disabled={loading}
onClick={() => setIsEditingThumbnail(true)}
className="bg-third text-textColor px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-tableBorder"
>
{media.thumbnail || newThumbnail
? 'Edit Thumbnail'
: 'Create Thumbnail'}
</button>
{(thumbnail || newThumbnail) && (
<button
disabled={loading}
onClick={() => {
setNewThumbnail(null);
setThumbnail(null);
}}
className="bg-red-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all flex-1 border border-red-700"
>
Clear Thumbnail
</button>
)}
</div>
</div>
) : (
<div>
{/* Back button */}
<div className="flex justify-start">
<button
onClick={() => setIsEditingThumbnail(false)}
className="text-textColor hover:text-white transition-colors flex items-center space-x-2"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 12H5M12 19L5 12L12 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Back</span>
</button>
</div>
{/* Thumbnail Editor */}
<CreateThumbnail
onSelect={(blob: Blob) => {
// 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}
/>
</div>
)}
</div>
</>
)}
{!isEditingThumbnail && (
<div className="flex space-x-2 !mt-[20px]">
<button
disabled={loading}
onClick={onClose}
className="flex-1 bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Cancel
</button>
<button
onClick={save}
className="flex-1 bg-forth text-white px-6 py-2 rounded-lg hover:bg-opacity-80 transition-all"
>
Save Changes
</button>
</div>
)}
</div>
</div>
</div>
</div>

View File

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

View File

@ -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<Media[]>([]);
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<Media[]>([]);
const ref = useRef<any>(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<{
<div className="w-full h-full relative group">
<div
onClick={() => 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]"
>
<svg

View File

@ -532,26 +532,26 @@ export const Editor: FC<{
/>
</div>
<div className="w-full h-[46px] pointer-events-none" />
<div className="flex bg-customColor2">
{setImages && (
<MultiMediaComponent
allData={allValues}
text={props.value}
label={t('attachments', 'Attachments')}
description=""
value={props.pictures}
name="image"
onChange={(value) => {
setImages(value.target.value);
}}
onOpen={() => {}}
onClose={() => {}}
/>
)}
</div>
</div>
</div>
</div>
<div className="flex bg-customColor2">
{setImages && (
<MultiMediaComponent
allData={allValues}
text={props.value}
label={t('attachments', 'Attachments')}
description=""
value={props.pictures}
name="image"
onChange={(value) => {
setImages(value.target.value);
}}
onOpen={() => {}}
onClose={() => {}}
/>
)}
</div>
<div className="absolute bottom-10px end-[25px]">
{(props?.totalChars || 0) > 0 && (
<div

View File

@ -60,6 +60,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
integrations,
setSelectedIntegrations,
locked,
activateExitButton,
} = useLaunchStore(
useShallow((state) => ({
hide: state.hide,
@ -73,6 +74,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
integrations: state.integrations,
setSelectedIntegrations: state.setSelectedIntegrations,
locked: state.locked,
activateExitButton: state.activateExitButton
}))
);
@ -96,6 +98,10 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
}, [existingData, mutate, modal]);
const askClose = useCallback(async () => {
if (!activateExitButton) {
return;
}
if (
await deleteDialog(
t(
@ -111,7 +117,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
}
modal.closeAll();
}
}, []);
}, [activateExitButton]);
const changeCustomer = useCallback(
(customer: string) => {

View File

@ -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<StoreState>()((set) => ({
set((state) => ({
postComment,
})),
setActivateExitButton: (activateExitButton: boolean) =>
set((state) => ({
activateExitButton,
})),
}));

View File

@ -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<any>(null);
const setActivateExitButton = useLaunchStore((e) => e.setActivateExitButton);
useEffect(() => {
setActivateExitButton(false);
return () => {
setActivateExitButton(true);
};
}, []);
const Component = useMemo(() => {
if (!thirdParty) {
return EmptyComponent;

View File

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

View File

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

View File

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

View File

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