diff options
author | fawn <fangrui_tong@brown.edu> | 2019-07-30 16:52:12 -0400 |
---|---|---|
committer | fawn <fangrui_tong@brown.edu> | 2019-07-30 16:52:12 -0400 |
commit | f7c0948910182f5f6cb2c10c216994e2bc7b91b0 (patch) | |
tree | 6d443543c7475f4104bf7b8a3be788bb3ce2a3ec /src/client/apis | |
parent | 78999b8b35267db9236bbb69e7e90e8691c59ba9 (diff) | |
parent | 8ca17d379ce7d3cc751408553b6819223d31a3e0 (diff) |
merged
Diffstat (limited to 'src/client/apis')
-rw-r--r-- | src/client/apis/youtube/YoutubeBox.scss | 124 | ||||
-rw-r--r-- | src/client/apis/youtube/YoutubeBox.tsx | 362 |
2 files changed, 486 insertions, 0 deletions
diff --git a/src/client/apis/youtube/YoutubeBox.scss b/src/client/apis/youtube/YoutubeBox.scss new file mode 100644 index 000000000..1fc91a9ae --- /dev/null +++ b/src/client/apis/youtube/YoutubeBox.scss @@ -0,0 +1,124 @@ +ul { + list-style-type: none; + padding-inline-start: 10px; +} + + +li { + margin: 4px; + display: inline-flex; +} + +li:hover { + cursor: pointer; + opacity: 0.8; +} + +.search_wrapper { + width: 100%; + display: inline-flex; + height: 175px; + + .video_duration { + // margin: 0; + // padding: 0; + border: 0; + background: transparent; + display: inline-block; + position: relative; + bottom: 25px; + left: 85%; + margin: 4px; + color: #FFFFFF; + background-color: rgba(0, 0, 0, 0.80); + padding: 2px 4px; + border-radius: 2px; + letter-spacing: .5px; + font-size: 1.2rem; + font-weight: 500; + line-height: 1.2rem; + + } + + .textual_info { + font-family: Arial, Helvetica, sans-serif; + + .videoTitle { + margin-left: 4px; + // display: inline-block; + color: #0D0D0D; + -webkit-line-clamp: 2; + display: block; + max-height: 4.8rem; + overflow: hidden; + font-size: 1.8rem; + font-weight: 400; + line-height: 2.4rem; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + white-space: normal; + display: -webkit-box; + } + + .channelName { + color:#606060; + margin-left: 4px; + font-size: 1.3rem; + font-weight: 400; + line-height: 1.8rem; + text-transform: none; + margin-top: 0px; + display: inline-block; + } + + .video_description { + margin-left: 4px; + // font-size: 12px; + color: #606060; + padding-top: 8px; + margin-bottom: 8px; + display: block; + line-height: 1.8rem; + max-height: 4.2rem; + overflow: hidden; + font-size: 1.3rem; + font-weight: 400; + text-transform: none; + } + + .publish_time { + //display: inline-block; + margin-left: 8px; + padding: 0; + border: 0; + background: transparent; + color: #606060; + max-width: 100%; + line-height: 1.8rem; + max-height: 3.6rem; + overflow: hidden; + font-size: 1.3rem; + font-weight: 400; + text-transform: none; + } + + .viewCount { + + margin-left: 8px; + padding: 0; + border: 0; + background: transparent; + color: #606060; + max-width: 100%; + line-height: 1.8rem; + max-height: 3.6rem; + overflow: hidden; + font-size: 1.3rem; + font-weight: 400; + text-transform: none; + } + + + + } +}
\ No newline at end of file diff --git a/src/client/apis/youtube/YoutubeBox.tsx b/src/client/apis/youtube/YoutubeBox.tsx new file mode 100644 index 000000000..7f9a3ad70 --- /dev/null +++ b/src/client/apis/youtube/YoutubeBox.tsx @@ -0,0 +1,362 @@ +import { action, observable, runInAction } from 'mobx'; +import { observer } from "mobx-react"; +import { Doc, DocListCastAsync } from "../../../new_fields/Doc"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { Utils } from "../../../Utils"; +import { DocServer } from "../../DocServer"; +import { Docs } from "../../documents/Documents"; +import { DocumentDecorations } from "../../views/DocumentDecorations"; +import { InkingControl } from "../../views/InkingControl"; +import { FieldView, FieldViewProps } from "../../views/nodes/FieldView"; +import "../../views/nodes/WebBox.scss"; +import "./YoutubeBox.scss"; +import React = require("react"); + +interface VideoTemplate { + thumbnailUrl: string; + videoTitle: string; + videoId: string; + duration: string; + channelTitle: string; + viewCount: string; + publishDate: string; + videoDescription: string; +} + +/** + * This class models the youtube search document that can be dropped on to canvas. + */ +@observer +export class YoutubeBox extends React.Component<FieldViewProps> { + + @observable YoutubeSearchElement: HTMLInputElement | undefined; + @observable searchResultsFound: boolean = false; + @observable searchResults: any[] = []; + @observable videoClicked: boolean = false; + @observable selectedVideoUrl: string = ""; + @observable lisOfBackUp: JSX.Element[] = []; + @observable videoIds: string | undefined; + @observable videoDetails: any[] = []; + @observable curVideoTemplates: VideoTemplate[] = []; + + + public static LayoutString() { return FieldView.LayoutString(YoutubeBox); } + + /** + * When component mounts, last search's results are laoded in based on the back up stored + * in the document of the props. + */ + async componentWillMount() { + //DocServer.getYoutubeChannels(); + let castedSearchBackUp = Cast(this.props.Document.cachedSearchResults, Doc); + let awaitedBackUp = await castedSearchBackUp; + let castedDetailBackUp = Cast(this.props.Document.cachedDetails, Doc); + let awaitedDetails = await castedDetailBackUp; + + + if (awaitedBackUp) { + + + let jsonList = await DocListCastAsync(awaitedBackUp!.json); + let jsonDetailList = await DocListCastAsync(awaitedDetails!.json); + + if (jsonList!.length !== 0) { + runInAction(() => this.searchResultsFound = true); + let index = 0; + //getting the necessary information from backUps and building templates that will be used to map in render + for (let video of jsonList!) { + + let videoId = await Cast(video.id, Doc); + let id = StrCast(videoId!.videoId); + let snippet = await Cast(video.snippet, Doc); + let videoTitle = this.filterYoutubeTitleResult(StrCast(snippet!.title)); + let thumbnail = await Cast(snippet!.thumbnails, Doc); + let thumbnailMedium = await Cast(thumbnail!.medium, Doc); + let thumbnailUrl = StrCast(thumbnailMedium!.url); + let videoDescription = StrCast(snippet!.description); + let pusblishDate = (this.roundPublishTime(StrCast(snippet!.publishedAt)))!; + let channelTitle = StrCast(snippet!.channelTitle); + let duration: string; + let viewCount: string; + if (jsonDetailList!.length !== 0) { + let contentDetails = await Cast(jsonDetailList![index].contentDetails, Doc); + let statistics = await Cast(jsonDetailList![index].statistics, Doc); + duration = this.convertIsoTimeToDuration(StrCast(contentDetails!.duration)); + viewCount = this.abbreviateViewCount(parseInt(StrCast(statistics!.viewCount)))!; + } + index = index + 1; + let newTemplate: VideoTemplate = { videoId: id, videoTitle: videoTitle, thumbnailUrl: thumbnailUrl, publishDate: pusblishDate, channelTitle: channelTitle, videoDescription: videoDescription, duration: duration!, viewCount: viewCount! }; + runInAction(() => this.curVideoTemplates.push(newTemplate)); + } + } + } + } + + _ignore = 0; + onPreWheel = (e: React.WheelEvent) => { + this._ignore = e.timeStamp; + } + onPrePointer = (e: React.PointerEvent) => { + this._ignore = e.timeStamp; + } + onPostPointer = (e: React.PointerEvent) => { + if (this._ignore !== e.timeStamp) { + e.stopPropagation(); + } + } + onPostWheel = (e: React.WheelEvent) => { + if (this._ignore !== e.timeStamp) { + e.stopPropagation(); + } + } + + /** + * Function that submits the title entered by user on enter press. + */ + onEnterKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 13) { + let submittedTitle = this.YoutubeSearchElement!.value; + this.YoutubeSearchElement!.value = ""; + this.YoutubeSearchElement!.blur(); + DocServer.getYoutubeVideos(submittedTitle, this.processesVideoResults); + + } + } + + /** + * The callback that is passed in to server, which functions as a way to + * get videos that is returned by search. It also makes a call to server + * to get details for the videos found. + */ + @action + processesVideoResults = (videos: any[]) => { + this.searchResults = videos; + if (this.searchResults.length > 0) { + this.searchResultsFound = true; + this.videoIds = ""; + videos.forEach((video) => { + if (this.videoIds === "") { + this.videoIds = video.id.videoId; + } else { + this.videoIds = this.videoIds! + ", " + video.id.videoId; + } + }); + //Asking for details that include duration and viewCount from server for videoIds + DocServer.getYoutubeVideoDetails(this.videoIds, this.processVideoDetails); + this.backUpSearchResults(videos); + if (this.videoClicked) { + this.videoClicked = false; + } + } + } + + /** + * The callback that is given to server to process and receive returned details about the videos. + */ + @action + processVideoDetails = (videoDetails: any[]) => { + this.videoDetails = videoDetails; + this.props.Document.cachedDetails = Docs.Get.DocumentHierarchyFromJson(videoDetails, "detailBackUp"); + } + + /** + * The function that stores the search results in the props document. + */ + backUpSearchResults = (videos: any[]) => { + this.props.Document.cachedSearchResults = Docs.Get.DocumentHierarchyFromJson(videos, "videosBackUp"); + } + + /** + * The function that filters out escaped characters returned by the api + * in the title of the videos. + */ + filterYoutubeTitleResult = (resultTitle: string) => { + let processedTitle: string = resultTitle.ReplaceAll("&", "&"); + processedTitle = processedTitle.ReplaceAll("'", "'"); + processedTitle = processedTitle.ReplaceAll(""", "\""); + return processedTitle; + } + + + + /** + * The function that converts ISO date, which is passed in, to normal date and finds the + * difference between today's date and that date, in terms of "ago" to imitate youtube. + */ + roundPublishTime = (publishTime: string) => { + let date = new Date(publishTime).getTime(); + let curDate = new Date().getTime(); + let timeDif = curDate - date; + let totalSeconds = timeDif / 1000; + let totalMin = totalSeconds / 60; + let totalHours = totalMin / 60; + let totalDays = totalHours / 24; + let totalMonths = totalDays / 30.417; + let totalYears = totalMonths / 12; + + + let truncYears = Math.trunc(totalYears); + let truncMonths = Math.trunc(totalMonths); + let truncDays = Math.trunc(totalDays); + let truncHours = Math.trunc(totalHours); + let truncMin = Math.trunc(totalMin); + let truncSec = Math.trunc(totalSeconds); + + let pluralCase = ""; + + if (truncYears !== 0) { + truncYears > 1 ? pluralCase = "s" : pluralCase = ""; + return truncYears + " year" + pluralCase + " ago"; + } else if (truncMonths !== 0) { + truncMonths > 1 ? pluralCase = "s" : pluralCase = ""; + return truncMonths + " month" + pluralCase + " ago"; + } else if (truncDays !== 0) { + truncDays > 1 ? pluralCase = "s" : pluralCase = ""; + return truncDays + " day" + pluralCase + " ago"; + } else if (truncHours !== 0) { + truncHours > 1 ? pluralCase = "s" : pluralCase = ""; + return truncHours + " hour" + pluralCase + " ago"; + } else if (truncMin !== 0) { + truncMin > 1 ? pluralCase = "s" : pluralCase = ""; + return truncMin + " minute" + pluralCase + " ago"; + } else if (truncSec !== 0) { + truncSec > 1 ? pluralCase = "s" : pluralCase = ""; + return truncSec + " second" + pluralCase + " ago"; + } + } + + /** + * The function that converts the passed in ISO time to normal duration time. + */ + convertIsoTimeToDuration = (isoDur: string) => { + + let convertedTime = isoDur.replace(/D|H|M/g, ":").replace(/P|T|S/g, "").split(":"); + + if (1 === convertedTime.length) { + 2 !== convertedTime[0].length && (convertedTime[0] = "0" + convertedTime[0]), convertedTime[0] = "0:" + convertedTime[0]; + } else { + for (var r = 1, l = convertedTime.length - 1; l >= r; r++) { + 2 !== convertedTime[r].length && (convertedTime[r] = "0" + convertedTime[r]); + } + } + + return convertedTime.join(":"); + } + + /** + * The function that rounds the viewCount to the nearest + * thousand, million or billion, given a viewCount number. + */ + abbreviateViewCount = (viewCount: number) => { + if (viewCount < 1000) { + return viewCount.toString(); + } else if (viewCount >= 1000 && viewCount < 1000000) { + return (Math.trunc(viewCount / 1000)) + "K"; + } else if (viewCount >= 1000000 && viewCount < 1000000000) { + return (Math.trunc(viewCount / 1000000)) + "M"; + } else if (viewCount >= 1000000000) { + return (Math.trunc(viewCount / 1000000000)) + "B"; + } + } + + /** + * The function that is called to decide on what'll be rendered by the component. + * It renders search Results if found. If user didn't do a new search, it renders from the videoTemplates + * generated by the backUps. If none present, renders nothing. + */ + renderSearchResultsOrVideo = () => { + if (this.searchResultsFound) { + if (this.searchResults.length !== 0) { + return <ul> + {this.searchResults.map((video, index) => { + let filteredTitle = this.filterYoutubeTitleResult(video.snippet.title); + let channelTitle = video.snippet.channelTitle; + let videoDescription = video.snippet.description; + let pusblishDate = this.roundPublishTime(video.snippet.publishedAt); + let duration; + let viewCount; + if (this.videoDetails.length !== 0) { + duration = this.convertIsoTimeToDuration(this.videoDetails[index].contentDetails.duration); + viewCount = this.abbreviateViewCount(this.videoDetails[index].statistics.viewCount); + } + + + return <li onClick={() => this.embedVideoOnClick(video.id.videoId, filteredTitle)} key={Utils.GenerateGuid()}> + <div className="search_wrapper"> + <div style={{ backgroundColor: "yellow" }}> + <img src={video.snippet.thumbnails.medium.url} /> + <span className="video_duration">{duration}</span> + </div> + <div className="textual_info"> + <span className="videoTitle">{filteredTitle}</span> + <span className="channelName">{channelTitle}</span> + <span className="viewCount">{viewCount}</span> + <span className="publish_time">{pusblishDate}</span> + <p className="video_description">{videoDescription}</p> + + </div> + </div> + </li>; + })} + </ul>; + } else if (this.curVideoTemplates.length !== 0) { + return <ul> + {this.curVideoTemplates.map((video: VideoTemplate) => { + return <li onClick={() => this.embedVideoOnClick(video.videoId, video.videoTitle)} key={Utils.GenerateGuid()}> + <div className="search_wrapper"> + <div style={{ backgroundColor: "yellow" }}> + <img src={video.thumbnailUrl} /> + <span className="video_duration">{video.duration}</span> + </div> + <div className="textual_info"> + <span className="videoTitle">{video.videoTitle}</span> + <span className="channelName">{video.channelTitle}</span> + <span className="viewCount">{video.viewCount}</span> + <span className="publish_time">{video.publishDate}</span> + <p className="video_description">{video.videoDescription}</p> + </div> + </div> + </li>; + })} + </ul>; + } + } else { + return (null); + } + } + + /** + * Given a videoId and title, creates a new youtube embedded url, and uses that + * to create a new video document. + */ + @action + embedVideoOnClick = (videoId: string, filteredTitle: string) => { + let embeddedUrl = "https://www.youtube.com/embed/" + videoId; + this.selectedVideoUrl = embeddedUrl; + let addFunction = this.props.addDocument!; + let newVideoX = NumCast(this.props.Document.x); + let newVideoY = NumCast(this.props.Document.y) + NumCast(this.props.Document.height); + + addFunction(Docs.Create.VideoDocument(embeddedUrl, { title: filteredTitle, width: 400, height: 315, x: newVideoX, y: newVideoY })); + this.videoClicked = true; + } + + render() { + let content = + <div style={{ width: "100%", height: "100%", position: "absolute" }} onWheel={this.onPostWheel} onPointerDown={this.onPostPointer} onPointerMove={this.onPostPointer} onPointerUp={this.onPostPointer}> + <input type="text" placeholder="Search for a video" onKeyDown={this.onEnterKeyDown} style={{ height: 40, width: "100%", border: "1px solid black", padding: 5, textAlign: "center" }} ref={(e) => this.YoutubeSearchElement = e!} /> + {this.renderSearchResultsOrVideo()} + </div>; + + let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; + + let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + return ( + <> + <div className={classname} > + {content} + </div> + {!frozen ? (null) : <div className="webBox-overlay" onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer} />} + </>); + } +}
\ No newline at end of file |