diff --git a/backend/src/bundles/public-video/enums/enums.ts b/backend/src/bundles/public-video/enums/enums.ts new file mode 100644 index 000000000..2f314a44f --- /dev/null +++ b/backend/src/bundles/public-video/enums/enums.ts @@ -0,0 +1 @@ +export { VideosApiPath, VideoValidationMessage } from 'shared'; diff --git a/backend/src/bundles/public-video/enums/public-videos-api-path.enum.ts b/backend/src/bundles/public-video/enums/public-videos-api-path.enum.ts new file mode 100644 index 000000000..d41af0187 --- /dev/null +++ b/backend/src/bundles/public-video/enums/public-videos-api-path.enum.ts @@ -0,0 +1,5 @@ +const PublicVideosApiPath = { + ROOT: '/', +} as const; + +export { PublicVideosApiPath }; diff --git a/backend/src/bundles/public-video/public-video.controller.ts b/backend/src/bundles/public-video/public-video.controller.ts new file mode 100644 index 000000000..f864f9128 --- /dev/null +++ b/backend/src/bundles/public-video/public-video.controller.ts @@ -0,0 +1,42 @@ +import { type PublicVideoService } from '~/bundles/public-video/public-video.service.js'; +import { + type ApiHandlerOptions, + type ApiHandlerResponse, + BaseController, +} from '~/common/controller/controller.js'; +import { ApiPath } from '~/common/enums/enums.js'; +import { HTTPCode, HTTPMethod } from '~/common/http/http.js'; +import { type Logger } from '~/common/logger/logger.js'; + +import { PublicVideosApiPath } from './enums/public-videos-api-path.enum.js'; + +class PublicVideoController extends BaseController { + private publicVideoService: PublicVideoService; + + public constructor(logger: Logger, publicVideoService: PublicVideoService) { + super(logger, ApiPath.PUBLIC_VIDEO); + + this.publicVideoService = publicVideoService; + + this.addRoute({ + path: PublicVideosApiPath.ROOT, + method: HTTPMethod.GET, + handler: (options) => this.findUrlByToken(options), + }); + } + + private async findUrlByToken( + options: ApiHandlerOptions, + ): Promise { + const headers = options.headers as Record; + const videoTokenHeader = headers['video_token']?.toString() ?? ''; + + return { + status: HTTPCode.OK, + payload: + await this.publicVideoService.findUrlByToken(videoTokenHeader), + }; + } +} + +export { PublicVideoController }; diff --git a/backend/src/bundles/public-video/public-video.service.ts b/backend/src/bundles/public-video/public-video.service.ts new file mode 100644 index 000000000..018cd2006 --- /dev/null +++ b/backend/src/bundles/public-video/public-video.service.ts @@ -0,0 +1,41 @@ +import { type VideoRepository } from '~/bundles/videos/video.repository.js'; +import { HTTPCode, HttpError } from '~/common/http/http.js'; +import { tokenService } from '~/common/services/services.js'; + +import { VideoValidationMessage } from './enums/enums.js'; + +class PublicVideoService { + private videoRepository: VideoRepository; + public constructor(videoRepository: VideoRepository) { + this.videoRepository = videoRepository; + } + + public async findUrlByToken(token: string): Promise { + const id = await tokenService.getIdFromToken(token); + + if (!id) { + this.throwVideoNotFoundError(); + } + + const video = await this.videoRepository.findById(id); + + if (!video) { + this.throwVideoNotFoundError(); + } + + const { url } = video.toObject(); + if (!url) { + this.throwVideoNotFoundError(); + } + return url; + } + + private throwVideoNotFoundError(): never { + throw new HttpError({ + message: VideoValidationMessage.VIDEO_DOESNT_EXIST, + status: HTTPCode.NOT_FOUND, + }); + } +} + +export { PublicVideoService }; diff --git a/backend/src/bundles/public-video/public-videos.ts b/backend/src/bundles/public-video/public-videos.ts new file mode 100644 index 000000000..592c1cedb --- /dev/null +++ b/backend/src/bundles/public-video/public-videos.ts @@ -0,0 +1,13 @@ +import { VideoModel } from '~/bundles/videos/video.model.js'; +import { VideoRepository } from '~/bundles/videos/video.repository.js'; +import { logger } from '~/common/logger/logger.js'; +import { imageService } from '~/common/services/services.js'; + +import { PublicVideoController } from './public-video.controller.js'; +import { PublicVideoService } from './public-video.service.js'; + +const videoRepository = new VideoRepository(VideoModel, imageService); +const videoService = new PublicVideoService(videoRepository); +const publicVideoController = new PublicVideoController(logger, videoService); + +export { publicVideoController }; diff --git a/backend/src/bundles/videos/video.controller.ts b/backend/src/bundles/videos/video.controller.ts index 2ec2314d8..45ce5db2f 100644 --- a/backend/src/bundles/videos/video.controller.ts +++ b/backend/src/bundles/videos/video.controller.ts @@ -109,6 +109,17 @@ class VideoController extends BaseController { }>, ), }); + this.addRoute({ + path: `${VideosApiPath.ID}${VideosApiPath.SHARE}`, + method: HTTPMethod.GET, + handler: (options) => { + return this.createVideoIdJWT( + options as ApiHandlerOptions<{ + params: VideoGetOneRequestDto; + }>, + ); + }, + }); } /** @@ -186,6 +197,43 @@ class VideoController extends BaseController { }; } + /** + * @swagger + * /videos/{id}/share: + * get: + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The video id + * description: Create a JWT for the video id + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Successful operation + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + */ + private async createVideoIdJWT( + options: ApiHandlerOptions<{ + params: VideoGetOneRequestDto; + }>, + ): Promise { + return { + status: HTTPCode.OK, + payload: await this.videoService.getVideoIdToken(options.params.id), + }; + } + /** * @swagger * /videos/: diff --git a/backend/src/bundles/videos/video.service.ts b/backend/src/bundles/videos/video.service.ts index 42c7fe4ae..aeffd0ef2 100644 --- a/backend/src/bundles/videos/video.service.ts +++ b/backend/src/bundles/videos/video.service.ts @@ -3,6 +3,7 @@ import { type VideoRepository } from '~/bundles/videos/video.repository.js'; import { HTTPCode, HttpError } from '~/common/http/http.js'; import { type ImageService } from '~/common/services/image/image.service.js'; import { type RemotionService } from '~/common/services/remotion/remotion.service.js'; +import { tokenService } from '~/common/services/services.js'; import { type Service } from '~/common/types/types.js'; import { VideoValidationMessage } from './enums/enums.js'; @@ -18,7 +19,6 @@ class VideoService implements Service { private videoRepository: VideoRepository; private remotionService: RemotionService; private imageService: ImageService; - public constructor( videoRepository: VideoRepository, remotionService: RemotionService, @@ -116,6 +116,11 @@ class VideoService implements Service { return isVideoDeleted; } + + public async getVideoIdToken(id: string): Promise { + const token = await tokenService.createToken(id, false); + return token.replaceAll('.', '~'); + } } export { VideoService }; diff --git a/backend/src/common/constants/white-routes.constants.ts b/backend/src/common/constants/white-routes.constants.ts index 4af3ddb56..5a592caa2 100644 --- a/backend/src/common/constants/white-routes.constants.ts +++ b/backend/src/common/constants/white-routes.constants.ts @@ -11,6 +11,10 @@ const WHITE_ROUTES = [ path: `/api/v1${ApiPath.AUTH}${AuthApiPath.SIGN_UP}`, method: HTTPMethod.POST, }, + { + path: `/api/v1${ApiPath.PUBLIC_VIDEO}/`, + method: HTTPMethod.GET, + }, { path: /\/v1\/documentation\/.*/, method: HTTPMethod.GET, diff --git a/backend/src/common/controller/base-controller.package.ts b/backend/src/common/controller/base-controller.package.ts index 3ec714afd..eb3bcfd70 100644 --- a/backend/src/common/controller/base-controller.package.ts +++ b/backend/src/common/controller/base-controller.package.ts @@ -49,7 +49,7 @@ class BaseController implements Controller { private mapRequest( request: Parameters[0], ): ApiHandlerOptions { - const { body, query, params, session, user } = request; + const { body, query, params, session, user, headers } = request; return { body, @@ -57,6 +57,7 @@ class BaseController implements Controller { params, session, user, + headers, }; } } diff --git a/backend/src/common/controller/types/api-handler-options.type.ts b/backend/src/common/controller/types/api-handler-options.type.ts index 0aee24e31..07838befe 100644 --- a/backend/src/common/controller/types/api-handler-options.type.ts +++ b/backend/src/common/controller/types/api-handler-options.type.ts @@ -4,6 +4,7 @@ type DefaultApiHandlerOptions = { params?: unknown; session?: unknown; user?: unknown; + headers?: unknown; }; type ApiHandlerOptions< @@ -14,6 +15,7 @@ type ApiHandlerOptions< params: T['params']; session: T['session']; user: T['user']; + headers: T['headers']; }; export { type ApiHandlerOptions }; diff --git a/backend/src/common/plugins/auth/auth-jwt.plugin.ts b/backend/src/common/plugins/auth/auth-jwt.plugin.ts index f5d41bc6d..01e7cc3bc 100644 --- a/backend/src/common/plugins/auth/auth-jwt.plugin.ts +++ b/backend/src/common/plugins/auth/auth-jwt.plugin.ts @@ -31,7 +31,7 @@ const authenticateJWT = fp((fastify, { routesWhiteList }, done) => { const [, token] = authHeader.split(' '); - const userId = await tokenService.getUserIdFromToken(token as string); + const userId = await tokenService.getIdFromToken(token as string); if (!userId) { throw new HttpError({ diff --git a/backend/src/common/server-application/server-application.ts b/backend/src/common/server-application/server-application.ts index feb24194d..432251224 100644 --- a/backend/src/common/server-application/server-application.ts +++ b/backend/src/common/server-application/server-application.ts @@ -3,6 +3,7 @@ import { avatarVideoController } from '~/bundles/avatar-videos/avatar-videos.js' import { avatarController } from '~/bundles/avatars/avatars.js'; import { chatController } from '~/bundles/chat/chat.js'; import { notificationController } from '~/bundles/notifications/notifications.js'; +import { publicVideoController } from '~/bundles/public-video/public-videos.js'; import { speechController } from '~/bundles/speech/speech.js'; import { templateController } from '~/bundles/templates/templates.js'; import { userController } from '~/bundles/users/users.js'; @@ -25,6 +26,7 @@ const apiV1 = new BaseServerAppApi( ...chatController.routes, ...speechController.routes, ...avatarVideoController.routes, + ...publicVideoController.routes, ...templateController.routes, ); diff --git a/backend/src/common/services/token/token.services.ts b/backend/src/common/services/token/token.services.ts index 3bef998a0..fe69dc4ce 100644 --- a/backend/src/common/services/token/token.services.ts +++ b/backend/src/common/services/token/token.services.ts @@ -10,11 +10,17 @@ class TokenService { this.expirationTime = expirationTime; } - public async createToken(userId: string): Promise { - return await new SignJWT({ userId }) - .setProtectedHeader({ alg: 'HS256' }) - .setExpirationTime(this.expirationTime) - .sign(this.secretKey); + public async createToken( + id: string, + expires: boolean = true, + ): Promise { + const jwt = new SignJWT({ id }).setProtectedHeader({ alg: 'HS256' }); + + if (expires) { + jwt.setExpirationTime(this.expirationTime); + } + + return await jwt.sign(this.secretKey); } public async verifyToken(token: string): Promise { @@ -26,9 +32,9 @@ class TokenService { } } - public async getUserIdFromToken(token: string): Promise { + public async getIdFromToken(token: string): Promise { const payload = await this.verifyToken(token); - return (payload?.['userId'] as string) || null; + return (payload?.['id'] as string) ?? null; } } diff --git a/frontend/.env.example b/frontend/.env.example index 9d1ec0623..2303ec6c4 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -10,3 +10,9 @@ VITE_APP_API_ORIGIN_URL=/api/v1 # VITE_APP_PROXY_SERVER_URL=http://localhost:3001 VITE_APP_SOCKET_ORIGIN_URL=/socket.io + +# +# DEPLOYMENT +# +VITE_APP_PUBLIC_URL=http://localhost:3000 + diff --git a/frontend/src/bundles/common/api/api.ts b/frontend/src/bundles/common/api/api.ts index b492f9ff0..70fd2a12c 100644 --- a/frontend/src/bundles/common/api/api.ts +++ b/frontend/src/bundles/common/api/api.ts @@ -2,6 +2,7 @@ import { config } from '~/framework/config/config.js'; import { http } from '~/framework/http/http.js'; import { storage } from '~/framework/storage/storage.js'; +import { PublicVideosApi } from './public-video-api/public-videos-api.js'; import { VideosApi } from './video-api/videos-api.js'; const videosApi = new VideosApi({ @@ -10,4 +11,10 @@ const videosApi = new VideosApi({ http, }); -export { videosApi }; +const publicVideosApi = new PublicVideosApi({ + baseUrl: config.ENV.API.ORIGIN_URL, + storage, + http, +}); + +export { publicVideosApi, videosApi }; diff --git a/frontend/src/bundles/common/api/public-video-api/enums/enums.ts b/frontend/src/bundles/common/api/public-video-api/enums/enums.ts new file mode 100644 index 000000000..7e0da47c3 --- /dev/null +++ b/frontend/src/bundles/common/api/public-video-api/enums/enums.ts @@ -0,0 +1 @@ +export { VideosApiPath } from 'shared'; diff --git a/frontend/src/bundles/common/api/public-video-api/public-videos-api.ts b/frontend/src/bundles/common/api/public-video-api/public-videos-api.ts new file mode 100644 index 000000000..096bfb43d --- /dev/null +++ b/frontend/src/bundles/common/api/public-video-api/public-videos-api.ts @@ -0,0 +1,44 @@ +import { ApiPath, ContentType } from '~/bundles/common/enums/enums.js'; +import { type Http, HTTPMethod } from '~/framework/http/http.js'; +import { BaseHttpApi } from '~/framework/http-api/http-api.js'; +import { type Storage } from '~/framework/storage/storage.js'; + +import { VideosApiPath } from './enums/enums.js'; + +type Constructor = { + baseUrl: string; + http: Http; + storage: Storage; +}; + +class PublicVideosApi extends BaseHttpApi { + public constructor({ baseUrl, http, storage }: Constructor) { + super({ path: ApiPath.PUBLIC_VIDEO, baseUrl, http, storage }); + } + + public async getVideoUrlFromJWT(jwt: string): Promise { + const headers = new Headers(); + headers.append('video_token', jwt.replaceAll('~', '.')); + + const options = { + method: HTTPMethod.GET, + contentType: ContentType.JSON, + hasAuth: true, + customHeaders: headers, + }; + + const response = await this.load( + this.getFullEndpoint(`${VideosApiPath.ROOT}`, {}), + options, + ); + + if (!response.ok) { + throw new Error( + `Failed to get video ID JWT: ${response.statusText}`, + ); + } + return await response.text(); + } +} + +export { PublicVideosApi }; diff --git a/frontend/src/bundles/common/api/video-api/videos-api.ts b/frontend/src/bundles/common/api/video-api/videos-api.ts index 3c8448a44..22238dec9 100644 --- a/frontend/src/bundles/common/api/video-api/videos-api.ts +++ b/frontend/src/bundles/common/api/video-api/videos-api.ts @@ -68,6 +68,22 @@ class VideosApi extends BaseHttpApi { return await response.json(); } + public async getVideo( + payload: UpdateVideoRequestDto, + id: string, + ): Promise { + const response = await this.load( + this.getFullEndpoint(VideosApiPath.ID, { id }), + { + method: HTTPMethod.GET, + contentType: ContentType.JSON, + hasAuth: true, + }, + ); + + return await response.json(); + } + public async deleteVideo(id: string): Promise { const response = await this.load( this.getFullEndpoint(`${VideosApiPath.ROOT}${id}`, {}), @@ -81,6 +97,27 @@ class VideosApi extends BaseHttpApi { await response.json(); } + + public async getVideoIdJWT(id: string): Promise { + const response = await this.load( + this.getFullEndpoint( + `${VideosApiPath.ROOT}${id}${VideosApiPath.SHARE}`, + {}, + ), + { + method: HTTPMethod.GET, + contentType: ContentType.JSON, + hasAuth: true, + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to get video ID JWT: ${response.statusText}`, + ); + } + return await response.text(); + } } export { VideosApi }; diff --git a/frontend/src/bundles/common/enums/app-route.enum.ts b/frontend/src/bundles/common/enums/app-route.enum.ts index fd181a45d..defc6e8ab 100644 --- a/frontend/src/bundles/common/enums/app-route.enum.ts +++ b/frontend/src/bundles/common/enums/app-route.enum.ts @@ -6,6 +6,7 @@ const AppRoute = { MY_AVATAR: '/my-avatar', ANY: '*', CREATE_AVATAR: '/create-avatar', + PREVIEW: '/preview', VOICES: '/voices', TEMPLATES: '/templates', } as const; diff --git a/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts b/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts index 1dfe8d66f..a060212db 100644 --- a/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts +++ b/frontend/src/bundles/common/icons/helper/icon-conversion.helper.ts @@ -5,6 +5,7 @@ import { faCircleUser, faCloudArrowDown, faCloudArrowUp, + faCopy, faEllipsisVertical, faFileLines, faFont, @@ -50,6 +51,7 @@ const Stop = convertIcon(faStop); const VideoCamera = convertIcon(faVideoCamera); const Image = convertIcon(faImage); const Circle = convertIcon(faCircle); +const Copy = convertIcon(faCopy); const HeartFill = convertIcon(faHeart); const HeartOutline = convertIcon(faHeartRegular); const Magnifying = convertIcon(faMagnifyingGlass); @@ -60,6 +62,7 @@ export { CircleUser, CloudArrowDown, CloudArrowUp, + Copy, EllipsisVertical, FileLines, Font, diff --git a/frontend/src/bundles/common/icons/icon-name.ts b/frontend/src/bundles/common/icons/icon-name.ts index 0230f26c6..fd1f311f2 100644 --- a/frontend/src/bundles/common/icons/icon-name.ts +++ b/frontend/src/bundles/common/icons/icon-name.ts @@ -20,6 +20,7 @@ import { CircleUser, CloudArrowDown, CloudArrowUp, + Copy, EllipsisVertical, FileLines, Font, @@ -80,6 +81,7 @@ const IconName = { IMAGE: Image, CIRCLE: Circle, ARROW_BACK: ArrowBackIcon, + COPY: Copy, VOICE: Voice, HEART_FILL: HeartFill, HEART_OUTLINE: HeartOutline, diff --git a/frontend/src/bundles/home/components/video-card/video-card.tsx b/frontend/src/bundles/home/components/video-card/video-card.tsx index 6a750bb81..3d3272ed7 100644 --- a/frontend/src/bundles/home/components/video-card/video-card.tsx +++ b/frontend/src/bundles/home/components/video-card/video-card.tsx @@ -24,6 +24,8 @@ import { useState, } from '~/bundles/common/hooks/hooks.js'; import { IconName, IconSize } from '~/bundles/common/icons/icons.js'; +import { notificationService } from '~/bundles/common/services/services.js'; +import { createVideoUrl } from '~/bundles/home/helpers/helpers.js'; import { actions as homeActions } from '~/bundles/home/store/home.js'; import { PlayerModal } from '../player-modal/player-modal.js'; @@ -94,6 +96,24 @@ const VideoCard: React.FC = ({ handleWarningModalClose(); }, [dispatch, handleWarningModalClose, id]); + const handleCopyButtonClick = useCallback(() => { + dispatch(homeActions.getJwt(id)) + .unwrap() + .then(async (jwt) => { + const token = await jwt; + const url = createVideoUrl(token); + await navigator.clipboard.writeText(url); + notificationService.success({ + message: 'Url copied to clipboard', + id: 'url-copied', + title: 'Success', + }); + }) + .catch((error) => { + throw new Error(`Failed to get video ID JWT: ${error}`); + }); + }, [dispatch, id]); + return ( = ({ Delete + } + onClick={handleCopyButtonClick} + > + + Copy video URL + + diff --git a/frontend/src/bundles/home/helpers/create-url-token.ts b/frontend/src/bundles/home/helpers/create-url-token.ts new file mode 100644 index 000000000..a4d778936 --- /dev/null +++ b/frontend/src/bundles/home/helpers/create-url-token.ts @@ -0,0 +1,8 @@ +import { config } from '~/framework/config/config.js'; + +const createVideoUrl = (jwtToken: string): string => { + const baseUrl = config.ENV.DEPLOYMENT.PUBLIC_URL; + return `${baseUrl}/preview/${jwtToken}`; +}; + +export { createVideoUrl }; diff --git a/frontend/src/bundles/home/helpers/helpers.ts b/frontend/src/bundles/home/helpers/helpers.ts new file mode 100644 index 000000000..2a9c6a4b1 --- /dev/null +++ b/frontend/src/bundles/home/helpers/helpers.ts @@ -0,0 +1 @@ +export { createVideoUrl } from './create-url-token.js'; diff --git a/frontend/src/bundles/home/store/actions.ts b/frontend/src/bundles/home/store/actions.ts index 77de9acb8..be65b7615 100644 --- a/frontend/src/bundles/home/store/actions.ts +++ b/frontend/src/bundles/home/store/actions.ts @@ -29,6 +29,14 @@ const deleteVideo = createAsyncThunk, string, AsyncThunkConfig>( }, ); +const getJwt = createAsyncThunk, string, AsyncThunkConfig>( + `${sliceName}/create-video-url`, + (payload, { extra }) => { + const { videosApi } = extra; + return videosApi.getVideoIdJWT(payload); + }, +); + const loadVoices = createAsyncThunk< GetVoicesResponseDto, undefined, @@ -49,4 +57,10 @@ const generateScriptSpeechPreview = createAsyncThunk< return speechApi.generateScriptSpeech(payload); }); -export { deleteVideo, generateScriptSpeechPreview, loadUserVideos, loadVoices }; +export { + deleteVideo, + generateScriptSpeechPreview, + getJwt, + loadUserVideos, + loadVoices, +}; diff --git a/frontend/src/bundles/home/store/home.ts b/frontend/src/bundles/home/store/home.ts index b7ad69cfe..d6e5c96e2 100644 --- a/frontend/src/bundles/home/store/home.ts +++ b/frontend/src/bundles/home/store/home.ts @@ -1,6 +1,7 @@ import { deleteVideo, generateScriptSpeechPreview, + getJwt, loadUserVideos, } from './actions.js'; import { actions } from './slice.js'; @@ -9,6 +10,7 @@ const allActions = { ...actions, deleteVideo, loadUserVideos, + getJwt, generateScriptSpeechPreview, }; diff --git a/frontend/src/bundles/preview/components/preview-wrapper.tsx b/frontend/src/bundles/preview/components/preview-wrapper.tsx new file mode 100644 index 000000000..bfa744f70 --- /dev/null +++ b/frontend/src/bundles/preview/components/preview-wrapper.tsx @@ -0,0 +1,10 @@ +import { useParams } from 'react-router-dom'; + +import { Preview } from '~/bundles/preview/pages/preview.js'; + +const PreviewWrapper: React.FC = () => { + const { jwt } = useParams<{ jwt: string }>(); + return ; +}; + +export { PreviewWrapper }; diff --git a/frontend/src/bundles/preview/pages/preview.tsx b/frontend/src/bundles/preview/pages/preview.tsx new file mode 100644 index 000000000..afad3b54a --- /dev/null +++ b/frontend/src/bundles/preview/pages/preview.tsx @@ -0,0 +1,83 @@ +import { useNavigate } from 'react-router-dom'; + +import { + Box, + Button, + Header, + Loader, + VideoPlayer, +} from '~/bundles/common/components/components.js'; +import { AppRoute } from '~/bundles/common/enums/enums.js'; +import { + useAppDispatch, + useCallback, + useEffect, + useState, +} from '~/bundles/common/hooks/hooks.js'; + +import { getUrl } from '../store/actions.js'; +import styles from './styles.module.css'; + +type Properties = { + jwt: string; +}; + +const Preview: React.FC = ({ jwt }) => { + const dispatch = useAppDispatch(); + const [url, setUrl] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchUrl = async (): Promise => { + try { + const result = await dispatch(getUrl(jwt)).unwrap(); + + setUrl(result); + } finally { + setIsLoading(false); + } + }; + + fetchUrl().catch((error) => { + throw new Error(error); + }); + }, [dispatch, jwt]); + + const navigate = useNavigate(); + + const handleClick = useCallback(() => { + navigate(AppRoute.ROOT); + }, [navigate]); + + if (isLoading) { + return ( + + + + ); + } + + return ( + +
+ } + /> + + + + + ); +}; + +export { Preview }; diff --git a/frontend/src/bundles/preview/pages/styles.module.css b/frontend/src/bundles/preview/pages/styles.module.css new file mode 100644 index 000000000..21d73a329 --- /dev/null +++ b/frontend/src/bundles/preview/pages/styles.module.css @@ -0,0 +1,22 @@ +.video-player { + height: 50vh; + width: 90vh; + position: relative; + background-color: var(--chakra-colors-background-600); +} +.loader-box { + background-color: var(--chakra-colors-background-900); + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} +.back-box { + background-color: var(--chakra-colors-background-600); + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + position: relative; + z-index: 0; +} diff --git a/frontend/src/bundles/preview/store/actions.ts b/frontend/src/bundles/preview/store/actions.ts new file mode 100644 index 000000000..7a72985bf --- /dev/null +++ b/frontend/src/bundles/preview/store/actions.ts @@ -0,0 +1,14 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import { type AsyncThunkConfig } from '~/bundles/common/types/types.js'; + +const getUrl = createAsyncThunk, string, AsyncThunkConfig>( + 'preview/create-video-url', + (payload, { extra }) => { + const { publicVideosApi } = extra; + + return publicVideosApi.getVideoUrlFromJWT(payload); + }, +); + +export { getUrl }; diff --git a/frontend/src/framework/config/base-config.package.ts b/frontend/src/framework/config/base-config.package.ts index f58044b4b..a4a408ea2 100644 --- a/frontend/src/framework/config/base-config.package.ts +++ b/frontend/src/framework/config/base-config.package.ts @@ -20,6 +20,9 @@ class BaseConfig implements Config { 'VITE_APP_SOCKET_ORIGIN_URL' ] as string, }, + DEPLOYMENT: { + PUBLIC_URL: import.meta.env['VITE_APP_PUBLIC_URL'] as string, + }, }; } } diff --git a/frontend/src/framework/config/types/environment-schema.type.ts b/frontend/src/framework/config/types/environment-schema.type.ts index 92adb2fd0..cdbe7675a 100644 --- a/frontend/src/framework/config/types/environment-schema.type.ts +++ b/frontend/src/framework/config/types/environment-schema.type.ts @@ -9,6 +9,9 @@ type EnvironmentSchema = { ORIGIN_URL: string; SOCKET_ORIGIN_URL: string; }; + DEPLOYMENT: { + PUBLIC_URL: string; + }; }; export { type EnvironmentSchema }; diff --git a/frontend/src/framework/http-api/base-http-api.package.ts b/frontend/src/framework/http-api/base-http-api.package.ts index 48fadbd2d..f64d71e5d 100644 --- a/frontend/src/framework/http-api/base-http-api.package.ts +++ b/frontend/src/framework/http-api/base-http-api.package.ts @@ -51,9 +51,15 @@ class BaseHttpApi implements HttpApi { hasAuth, credentials = 'same-origin', keepAlive = false, + customHeaders, } = options; const headers = await this.getHeaders(contentType, hasAuth); + if (customHeaders) { + for (const [key, value] of customHeaders) { + headers.append(key, value); + } + } const response = await this.http.load(path, { method, diff --git a/frontend/src/framework/http-api/types/http-api-options.type.ts b/frontend/src/framework/http-api/types/http-api-options.type.ts index 2a635c599..4c44b70b7 100644 --- a/frontend/src/framework/http-api/types/http-api-options.type.ts +++ b/frontend/src/framework/http-api/types/http-api-options.type.ts @@ -11,6 +11,7 @@ type HTTPApiOptions = Omit< payload?: HttpOptions['payload']; credentials?: HttpOptions['credentials']; keepAlive?: HttpOptions['keepAlive']; + customHeaders?: HttpOptions['headers']; }; export { type HTTPApiOptions }; diff --git a/frontend/src/framework/store/store.package.ts b/frontend/src/framework/store/store.package.ts index 8b8ef4504..804612365 100644 --- a/frontend/src/framework/store/store.package.ts +++ b/frontend/src/framework/store/store.package.ts @@ -9,7 +9,7 @@ import { authApi } from '~/bundles/auth/auth.js'; import { reducer as authReducer } from '~/bundles/auth/store/auth.js'; import { chatApi } from '~/bundles/chat/chat.js'; import { reducer as chatReducer } from '~/bundles/chat/store/chat.js'; -import { videosApi } from '~/bundles/common/api/api.js'; +import { publicVideosApi, videosApi } from '~/bundles/common/api/api.js'; import { AppEnvironment } from '~/bundles/common/enums/enums.js'; import { draftMiddleware, @@ -44,6 +44,7 @@ type ExtraArguments = { chatApi: typeof chatApi; templatesApi: typeof templatesApi; storage: typeof storage; + publicVideosApi: typeof publicVideosApi; }; class Store { @@ -90,6 +91,7 @@ class Store { chatApi, templatesApi, storage, + publicVideosApi, }; } } diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index eacee362f..1da9e0f15 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -6,6 +6,7 @@ import { NotFound } from '~/bundles/common/pages/not-found/not-found.js'; import { CreateAvatar } from '~/bundles/create-avatar/pages/create-avatar.js'; import { Home } from '~/bundles/home/pages/home.js'; import { MyAvatar } from '~/bundles/my-avatar/pages/my-avatar.js'; +import { PreviewWrapper } from '~/bundles/preview/components/preview-wrapper.js'; import { Studio } from '~/bundles/studio/pages/studio.js'; import { Templates } from '~/bundles/template/pages/templates.js'; import { Voices } from '~/bundles/voices/pages/voices.js'; @@ -75,6 +76,10 @@ const routes = [ path: AppRoute.ANY, element: , }, + { + path: `${AppRoute.PREVIEW}/:jwt`, + element: , + }, ], }, ]; diff --git a/shared/src/bundles/videos/enums/videos-api-path.enum.ts b/shared/src/bundles/videos/enums/videos-api-path.enum.ts index 82abbdcfa..0832c0367 100644 --- a/shared/src/bundles/videos/enums/videos-api-path.enum.ts +++ b/shared/src/bundles/videos/enums/videos-api-path.enum.ts @@ -1,6 +1,7 @@ const VideosApiPath = { ROOT: '/', ID: '/:id', + SHARE: '/share', } as const; export { VideosApiPath }; diff --git a/shared/src/enums/api-path.enum.ts b/shared/src/enums/api-path.enum.ts index e271483eb..e223afe1d 100644 --- a/shared/src/enums/api-path.enum.ts +++ b/shared/src/enums/api-path.enum.ts @@ -8,6 +8,7 @@ const ApiPath = { CHAT: '/chat', SPEECH: '/speech', AVATAR_VIDEO: '/avatar-video', + PUBLIC_VIDEO: '/public-video', TEMPLATES: '/templates', } as const;