feat: fix tiktok

This commit is contained in:
Nevo David 2025-07-04 22:52:12 +07:00
parent f1f1c891ef
commit ab0b29451f
12 changed files with 163 additions and 187 deletions

View File

@ -84,7 +84,7 @@ export const useMediaSettings = () => {
};
export const CreateThumbnail: FC<{
onSelect: (blob: Blob) => void;
onSelect: (blob: Blob, timestampMs: number) => void;
media:
| {
id: string;
@ -100,12 +100,10 @@ export const CreateThumbnail: FC<{
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) {
@ -150,11 +148,14 @@ export const CreateThumbnail: FC<{
// Draw current frame to canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Get timestamp in milliseconds
const timestampMs = Math.round(currentTime * 1000);
// Convert canvas to blob
canvas.toBlob(
(blob: Blob | null) => {
if (blob) {
onSelect(blob);
onSelect(blob, timestampMs);
}
setIsCapturing(false);
},
@ -178,10 +179,13 @@ export const CreateThumbnail: FC<{
tempCanvas.height = video.videoHeight;
tempCtx.drawImage(video, 0, 0);
// Get timestamp in milliseconds
const timestampMs = Math.round(currentTime * 1000);
tempCanvas.toBlob(
(blob: Blob | null) => {
if (blob) {
onSelect(blob);
onSelect(blob, timestampMs);
}
setIsCapturing(false);
},
@ -198,7 +202,7 @@ export const CreateThumbnail: FC<{
setIsCapturing(false);
}
}
}, [onSelect]);
}, [onSelect, currentTime]);
const formatTime = useCallback((seconds: number) => {
const mins = Math.floor(seconds / 60);
@ -206,156 +210,57 @@ export const CreateThumbnail: FC<{
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 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>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{mode === 'frame' ? (
{isLoaded && (
<>
<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"
<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%)`,
}}
/>
<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 className="flex justify-between text-sm text-textColor">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</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"
<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"
>
<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>
{isCapturing ? 'Capturing...' : 'Select This Frame'}
</button>
</div>
</>
)}
<style jsx>{`
@ -394,7 +299,7 @@ export const MediaComponentInner: FC<{
alt: string;
}) => void;
media:
| { id: string; name: string; path: string; thumbnail: string; alt: string }
| { id: string; name: string; path: string; thumbnail: string; alt: string, thumbnailTimestamp?: number }
| undefined;
}> = (props) => {
const { onClose, onSelect, media } = props;
@ -404,7 +309,12 @@ export const MediaComponentInner: FC<{
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);
const [thumbnail, setThumbnail] = useState<string | null>(
props.media?.thumbnail || null
);
const [thumbnailTimestamp, setThumbnailTimestamp] = useState<number | null>(
props.media?.thumbnailTimestamp || null
);
useEffect(() => {
setActivateExitButton(false);
@ -437,13 +347,14 @@ export const MediaComponentInner: FC<{
id: props.media.id,
alt: altText,
thumbnail: path,
thumbnailTimestamp: thumbnailTimestamp,
}),
})
).json();
onSelect(media);
onClose();
}, [altText, newThumbnail, thumbnail]);
}, [altText, newThumbnail, thumbnail, thumbnailTimestamp]);
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 bg-black/40">
@ -561,13 +472,14 @@ export const MediaComponentInner: FC<{
{/* Thumbnail Editor */}
<CreateThumbnail
onSelect={(blob: Blob) => {
onSelect={(blob: Blob, timestampMs: number) => {
// 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);
setThumbnailTimestamp(timestampMs);
setIsEditingThumbnail(false);
};
reader.readAsDataURL(blob);

View File

@ -489,6 +489,7 @@ export const MultiMediaComponent: FC<{
path: string;
alt?: string;
thumbnail?: string;
thumbnailTimestamp?: number;
}>;
};
}) => void;

View File

@ -249,7 +249,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
value: post.values.map((value: any) => ({
...(value.id ? { id: value.id } : {}),
content: value.content.slice(0, post.maximumCharacters || 1000000),
image: value.media.map(({ id, path }: any) => ({ id, path })) || [],
image: value.media.map(({ id, path, alt, thumbnail, thumbnailTimestamp }: any) => ({ id, path, alt, thumbnail, thumbnailTimestamp })) || [],
})),
})),
};

View File

@ -203,6 +203,14 @@ const TikTokSettings: FC<{
{t('allow_user_to', 'Allow User To:')}
</div>
<div className="flex gap-[40px]">
<Checkbox
label={t('label_comments', 'Comments')}
variant="hollow"
disabled={isUploadMode}
{...register('comment', {
value: true,
})}
/>
<Checkbox
variant="hollow"
label={t('label_duet', 'Duet')}
@ -219,14 +227,6 @@ const TikTokSettings: FC<{
value: false,
})}
/>
<Checkbox
label={t('label_comments', 'Comments')}
variant="hollow"
disabled={isUploadMode}
{...register('comment', {
value: false,
})}
/>
</div>
<hr className="my-[15px] mb-[25px] border-tableBorder" />
<div className="flex flex-col">

View File

@ -56,6 +56,7 @@ export class MediaRepository {
data: {
alt: data.alt,
thumbnail: data.thumbnail,
thumbnailTimestamp: data.thumbnailTimestamp,
},
select: {
id: true,
@ -63,6 +64,7 @@ export class MediaRepository {
alt: true,
thumbnail: true,
path: true,
thumbnailTimestamp: true,
},
});
}
@ -94,6 +96,7 @@ export class MediaRepository {
path: true,
thumbnail: true,
alt: true,
thumbnailTimestamp: true,
},
skip: pageNum * 28,
take: 28,

View File

@ -197,20 +197,21 @@ model Star {
}
model Media {
id String @id @default(uuid())
name String
path String
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())
updatedAt DateTime @updatedAt
userPicture User[]
agencies SocialMediaAgency[]
deletedAt DateTime?
id String @id @default(uuid())
name String
path String
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
thumbnail String?
thumbnailTimestamp Int?
alt String?
fileSize Int @default(0)
type String @default("image")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userPicture User[]
agencies SocialMediaAgency[]
deletedAt DateTime?
@@index([name])
@@index([organizationId])

View File

@ -1,4 +1,4 @@
import { IsDefined, IsString } from 'class-validator';
import { IsDefined, IsString, IsUrl, ValidateIf } from 'class-validator';
export class MediaDto {
@IsString()
@ -8,4 +8,12 @@ export class MediaDto {
@IsString()
@IsDefined()
path: string;
@ValidateIf((o) => o.alt)
@IsString()
alt?: string;
@ValidateIf((o) => o.thumbnail)
@IsUrl()
thumbnail?: string;
}

View File

@ -1,4 +1,4 @@
import { IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
import { IsNumber, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
export class SaveMediaInformationDto {
@IsString()
@ -10,4 +10,8 @@ export class SaveMediaInformationDto {
@IsUrl()
@ValidateIf((o) => !!o.thumbnail)
thumbnail: string;
@IsNumber()
@ValidateIf((o) => !!o.thumbnailTimestamp)
thumbnailTimestamp: number;
}

View File

@ -0,0 +1,41 @@
import { Transform, Type } from 'class-transformer';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class IntegrationSettingsTransformer {
constructor(private integrationService: IntegrationService) {}
async transformPost(post: any, orgId: string) {
if (!post.integration?.id || !post.settings) {
return post;
}
try {
// Get the integration from the database
const integration = await this.integrationService.getIntegrationById(
orgId,
post.integration.id
);
if (integration?.providerIdentifier) {
// Set the __type field based on the provider identifier
post.settings.__type = integration.providerIdentifier;
}
} catch (error) {
// If there's an error fetching the integration, we'll let validation handle it
console.error('Error fetching integration for settings transform:', error);
}
return post;
}
}
// Custom property transformer for individual Post objects
export const TransformIntegrationSettings = (orgId: string) => {
return Transform(({ value, obj }) => {
// This will be handled by the service layer instead of transformer
// since we need async database access
return value;
});
};

View File

@ -104,6 +104,7 @@ export type MediaContent = {
path: string;
alt?: string;
thumbnail?: string;
thumbnailTimestamp?: number;
};
export interface SocialProvider

View File

@ -241,6 +241,7 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
): Promise<PostResponse[]> {
const [firstPost, ...comments] = postDetails;
console.log(firstPost);
const {
data: { publish_id },
} = await (
@ -256,11 +257,13 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
...((firstPost?.settings?.content_posting_method || 'DIRECT_POST') === 'DIRECT_POST'
...((firstPost?.settings?.content_posting_method ||
'DIRECT_POST') === 'DIRECT_POST'
? {
post_info: {
title: firstPost.message,
privacy_level: firstPost.settings.privacy_level || 'PUBLIC_TO_EVERYONE',
privacy_level:
firstPost.settings.privacy_level || 'PUBLIC_TO_EVERYONE',
disable_duet: !firstPost.settings.duet || false,
disable_comment: !firstPost.settings.comment || false,
disable_stitch: !firstPost.settings.stitch || false,
@ -283,6 +286,12 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
source_info: {
source: 'PULL_FROM_URL',
video_url: firstPost?.media?.[0]?.path!,
...(firstPost?.media?.[0]?.thumbnailTimestamp!
? {
video_cover_timestamp_ms:
firstPost?.media?.[0]?.thumbnailTimestamp!,
}
: {}),
},
}
: {

View File

@ -23,18 +23,14 @@ export const Checkbox = forwardRef<
const { checked, className, label, disableForm, variant } = props;
const form = useFormContext();
const register = disableForm ? {} : form.register(props.name!);
const watch = disableForm
? undefined
: useWatch({
name: props.name!,
});
const [currentStatus, setCurrentStatus] = useState(watch || checked);
const watch = disableForm ? false : form.watch(props.name!);
const val = watch || checked;
const changeStatus = useCallback(() => {
setCurrentStatus(!currentStatus);
props?.onChange?.({
target: {
name: props.name!,
value: !currentStatus,
value: !val,
},
});
if (!disableForm) {
@ -42,16 +38,16 @@ export const Checkbox = forwardRef<
register?.onChange?.({
target: {
name: props.name!,
value: !currentStatus,
value: !val,
},
});
}
}, [currentStatus]);
}, [val]);
return (
<div className="flex gap-[10px]">
<div
ref={ref}
{...register}
{...disableForm ? {} : form.register(props.name!)}
onClick={changeStatus}
className={clsx(
'cursor-pointer rounded-[4px] select-none w-[24px] h-[24px] justify-center items-center flex',
@ -61,7 +57,7 @@ export const Checkbox = forwardRef<
className
)}
>
{currentStatus && (
{val && (
<div>
<svg
xmlns="http://www.w3.org/2000/svg"