jbilcke-hf HF staff commited on
Commit
38d787b
1 Parent(s): 964db57

we can now post comments

Browse files
src/app/interface/action-button/index.tsx CHANGED
@@ -8,23 +8,31 @@ export const actionButtonClassName = cn(
8
  `rounded-2xl`,
9
  `cursor-pointer`,
10
  `text-xs lg:text-sm font-medium`,
11
- `bg-neutral-700/50 hover:bg-neutral-700/90 text-zinc-100`,
12
  )
13
 
14
  export function ActionButton({
15
  className,
16
  children,
17
  href,
 
 
 
18
  onClick,
19
  }: {
20
  className?: string
21
  children?: ReactNode
22
  href?: string
 
23
  onClick?: () => void
24
  }) {
25
 
26
  const classNames = cn(
27
  actionButtonClassName,
 
 
 
 
 
28
  className,
29
  )
30
 
 
8
  `rounded-2xl`,
9
  `cursor-pointer`,
10
  `text-xs lg:text-sm font-medium`,
 
11
  )
12
 
13
  export function ActionButton({
14
  className,
15
  children,
16
  href,
17
+
18
+ // by default most buttons are just secondary ("neutral") buttons
19
+ variant = "secondary",
20
  onClick,
21
  }: {
22
  className?: string
23
  children?: ReactNode
24
  href?: string
25
+ variant?: "primary" | "secondary" | "ghost"
26
  onClick?: () => void
27
  }) {
28
 
29
  const classNames = cn(
30
  actionButtonClassName,
31
+ variant === "ghost"
32
+ ? `bg-transparent hover:bg-transparent text-zinc-100`
33
+ : variant === "primary"
34
+ ? `bg-lime-700/80 hover:bg-lime-700 text-zinc-100`
35
+ : `bg-neutral-700/50 hover:bg-neutral-700/90 text-zinc-100`,
36
  className,
37
  )
38
 
src/app/interface/comment-card/index.tsx CHANGED
@@ -1,22 +1,26 @@
1
  import { cn } from "@/lib/utils"
2
- import { VideoComment } from "@/types"
3
  import { useEffect, useState } from "react"
4
  import { DefaultAvatar } from "../default-avatar"
 
5
 
6
  export function CommentCard({
7
  comment,
8
  replies = []
9
  }: {
10
- comment?: VideoComment,
11
- replies: VideoComment[]
12
  }) {
13
 
14
- const [userThumbnail, setUserThumbnail] = useState(comment?.user?.thumbnail || "")
 
 
 
15
 
16
  useEffect(() => {
17
- setUserThumbnail(comment?.user?.thumbnail || "")
18
 
19
- }, [comment?.user?.thumbnail])
20
 
21
  if (!comment) { return null }
22
 
@@ -33,33 +37,67 @@ export function CommentCard({
33
 
34
  return (
35
  <div className={cn(
36
- `flex flex-col`,
37
 
38
  )}>
39
  {/* THE COMMENT INFO - HORIZONTAL */}
40
  <div className={cn(
41
- `flex flex-col`,
 
42
 
43
  )}>
44
- <div
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  className={cn(
46
- `flex flex-col items-center justify-center`,
47
- `rounded-full overflow-hidden`,
48
- `w-26 h-26`
49
  )}
50
  >
51
- {comment.user.thumbnail
52
- ? <img
53
- src={comment.user.thumbnail}
54
- onError={handleBadUserThumbnail}
55
- />
56
- : <DefaultAvatar
57
- username={comment.user.userName}
58
- bgColor="#fde047"
59
- textColor="#1c1917"
60
- width={104}
61
- roundShape
62
- />}
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </div>
64
  </div>
65
 
 
1
  import { cn } from "@/lib/utils"
2
+ import { CommentInfo } from "@/types"
3
  import { useEffect, useState } from "react"
4
  import { DefaultAvatar } from "../default-avatar"
5
+ import { formatTimeAgo } from "@/lib/formatTimeAgo"
6
 
7
  export function CommentCard({
8
  comment,
9
  replies = []
10
  }: {
11
+ comment?: CommentInfo,
12
+ replies: CommentInfo[]
13
  }) {
14
 
15
+ const isLongContent = (comment?.message.length || 0) > 370
16
+
17
+ const [userThumbnail, setUserThumbnail] = useState(comment?.userInfo?.thumbnail || "")
18
+ const [isExpanded, setExpanded] = useState(false)
19
 
20
  useEffect(() => {
21
+ setUserThumbnail(comment?.userInfo?.thumbnail || "")
22
 
23
+ }, [comment?.userInfo?.thumbnail])
24
 
25
  if (!comment) { return null }
26
 
 
37
 
38
  return (
39
  <div className={cn(
40
+ `flex flex-col w-full`,
41
 
42
  )}>
43
  {/* THE COMMENT INFO - HORIZONTAL */}
44
  <div className={cn(
45
+ `flex flex-row w-full`,
46
+ // `space-x-3`
47
 
48
  )}>
49
+ <div
50
+ // className="flex flex-col w-10 pr-13 overflow-hidden"
51
+ className="flex flex-none flex-col w-10 pr-13 overflow-hidden">
52
+ {
53
+ userThumbnail ?
54
+ <div className="flex w-9 rounded-full overflow-hidden">
55
+ <img
56
+ src={userThumbnail}
57
+ onError={handleBadUserThumbnail}
58
+ />
59
+ </div>
60
+ : <DefaultAvatar
61
+ username={comment?.userInfo?.userName}
62
+ bgColor="#fde047"
63
+ textColor="#1c1917"
64
+ width={36}
65
+ roundShape
66
+ />}
67
+ </div>
68
+
69
+ {/* USER INFO AND ACTUAL MESSAGE */}
70
+ <div
71
  className={cn(
72
+ `flex flex-col items-start justify-center`,
73
+ `space-y-1.5`,
 
74
  )}
75
  >
76
+ <div className="flex flex-row space-x-3">
77
+ <div className="text-xs font-medium text-zinc-100">@{comment?.userInfo?.userName}</div>
78
+ <div className="text-xs font-medium text-neutral-400">{formatTimeAgo(comment.updatedAt)}</div>
79
+ </div>
80
+ <p className={cn(
81
+ `text-sm font-normal`,
82
+ `shrink`,
83
+ `overflow-hidden break-words`,
84
+ isExpanded ? `` : `line-clamp-4`
85
+ )}>{
86
+ comment.message
87
+ }</p>
88
+ {isLongContent &&
89
+ <div className={cn(
90
+ `flex`,
91
+ `text-sm font-medium text-neutral-400`,
92
+ `cursor-pointer`,
93
+ `hover:underline`
94
+ )}
95
+ onClick={() => {
96
+ setExpanded(!isExpanded)
97
+ }}
98
+ >
99
+ {isExpanded ? 'Read less' : 'Read more'}
100
+ </div>}
101
  </div>
102
  </div>
103
 
src/app/interface/comment-list/index.tsx CHANGED
@@ -1,18 +1,19 @@
1
  "use client"
2
 
3
  import { cn } from "@/lib/utils"
4
- import { VideoComment } from "@/types"
5
  import { CommentCard } from "../comment-card"
6
 
7
  export function CommentList({
8
  comments = []
9
  }: {
10
- comments: VideoComment[]
11
  }) {
12
 
13
  return (
14
  <div className={cn(
15
  `flex flex-col`,
 
16
  `w-full space-y-4`
17
  )}>
18
  {comments.map(comment => (
 
1
  "use client"
2
 
3
  import { cn } from "@/lib/utils"
4
+ import { CommentInfo } from "@/types"
5
  import { CommentCard } from "../comment-card"
6
 
7
  export function CommentList({
8
  comments = []
9
  }: {
10
+ comments: CommentInfo[]
11
  }) {
12
 
13
  return (
14
  <div className={cn(
15
  `flex flex-col`,
16
+ `pt-6`,
17
  `w-full space-y-4`
18
  )}>
19
  {comments.map(comment => (
src/app/interface/like-button/generic.tsx CHANGED
@@ -33,13 +33,13 @@ export function GenericLikeButton({
33
  numberOfDislikes?: number
34
  }) {
35
 
36
- const hasAlreadyVoted = isLikedByUser || isDislikedByUser
37
-
38
  const classNames = cn(
39
  likeButtonClassName,
40
  className,
41
  )
42
 
 
 
43
 
44
  return (
45
  <div className={classNames}>
@@ -47,7 +47,7 @@ export function GenericLikeButton({
47
  `flex flex-row items-center justify-center`,
48
  `cursor-pointer rounded-l-full overflow-hidden`,
49
  `hover:bg-neutral-700/90`,
50
- `space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-3 lg:pr-4 h-8 lg:h-9`
51
  )}
52
  onClick={() => {
53
  try {
@@ -57,15 +57,17 @@ export function GenericLikeButton({
57
  }}}
58
  >
59
  <div>{
60
- isLikedByUser ? <RiThumbUpFill /> : <RiThumbUpLine />
 
 
61
  }</div>
62
- <div>{formatLargeNumber(Math.max(0, numberOfLikes))}</div>
63
  </div>
64
  <div className={cn(
65
  `flex flex-row items-center justify-center`,
66
  `cursor-pointer rounded-r-full overflow-hidden`,
67
  `hover:bg-neutral-700/90`,
68
- `space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-3 lg:pr-4 h-8 lg:h-9`
69
  )}
70
  onClick={() => {
71
  try {
@@ -74,10 +76,13 @@ export function GenericLikeButton({
74
 
75
  }}}
76
  >
 
77
  <div>{
78
- isDislikedByUser ? <RiThumbDownFill /> : <RiThumbDownLine />
 
 
79
  }</div>
80
- <div>{formatLargeNumber(Math.max(0, numberOfDislikes))}</div>
81
  </div>
82
  </div>
83
  )
 
33
  numberOfDislikes?: number
34
  }) {
35
 
 
 
36
  const classNames = cn(
37
  likeButtonClassName,
38
  className,
39
  )
40
 
41
+ const nbLikes = Math.max(0, numberOfLikes)
42
+ const nbDislikes = Math.max(0, numberOfDislikes)
43
 
44
  return (
45
  <div className={classNames}>
 
47
  `flex flex-row items-center justify-center`,
48
  `cursor-pointer rounded-l-full overflow-hidden`,
49
  `hover:bg-neutral-700/90`,
50
+ `space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-1 lg:pr-1 h-8 lg:h-9`
51
  )}
52
  onClick={() => {
53
  try {
 
57
  }}}
58
  >
59
  <div>{
60
+ isLikedByUser
61
+ ? <RiThumbUpFill className="w-5 h-5" />
62
+ : <RiThumbUpLine className="w-5 h-5" />
63
  }</div>
64
+ <div>{nbLikes > 0 ? formatLargeNumber(nbLikes) : ""}</div>
65
  </div>
66
  <div className={cn(
67
  `flex flex-row items-center justify-center`,
68
  `cursor-pointer rounded-r-full overflow-hidden`,
69
  `hover:bg-neutral-700/90`,
70
+ `space-x-1.5 lg:space-x-2 pl-2 lg:pl-3 pr-2 lg:pr-3 h-8 lg:h-9`
71
  )}
72
  onClick={() => {
73
  try {
 
76
 
77
  }}}
78
  >
79
+ <div className="border-l border-l-zinc-600 h-[70%]">&nbsp;</div>
80
  <div>{
81
+ isDislikedByUser
82
+ ? <RiThumbDownFill className="w-5 h-5" />
83
+ : <RiThumbDownLine className="w-5 h-5" />
84
  }</div>
85
+ <div>{nbDislikes > 0 ? formatLargeNumber(numberOfDislikes) : ""}</div>
86
  </div>
87
  </div>
88
  )
src/app/interface/media-list/index.tsx CHANGED
@@ -39,7 +39,7 @@ export function MediaList({
39
  layout === "table"
40
  ? `flex flex-col` :
41
  layout === "grid"
42
- ? `grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4` :
43
  layout === "vertical"
44
  ? `grid grid-cols-1 gap-2`
45
  : `flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4`,
 
39
  layout === "table"
40
  ? `flex flex-col` :
41
  layout === "grid"
42
+ ? `grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4` :
43
  layout === "vertical"
44
  ? `grid grid-cols-1 gap-2`
45
  : `flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4`,
src/app/interface/video-card/index.tsx CHANGED
@@ -77,7 +77,9 @@ export function VideoCard({
77
  <div
78
  className={cn(
79
  `w-full flex`,
80
- isCompact ? `flex-row h-24 py-1 space-x-2` : `flex-col space-y-3`,
 
 
81
  `bg-line-900`,
82
  `cursor-pointer`,
83
  className,
@@ -90,13 +92,14 @@ export function VideoCard({
90
  <div
91
  className={cn(
92
  `flex flex-col items-center justify-center`,
93
- `rounded-xl overflow-hidden`,
94
- isCompact ? `w-42 h-[94px]` : `aspect-video`
95
  )}
96
  >
97
  <div className={cn(
98
  `relative w-full`,
99
- isCompact ? `w-42 h-[94px]` : `aspect-video`
 
 
100
  )}>
101
  {mediaThumbnailReady && shouldLoadMedia
102
  ? <video
@@ -110,7 +113,8 @@ export function VideoCard({
110
  src={media.assetUrl}
111
  className={cn(
112
  `w-full h-full`,
113
- `aspect-video`,
 
114
  duration > 0 ? `opacity-100`: 'opacity-0',
115
  `transition-all duration-500`,
116
  )}
@@ -121,9 +125,8 @@ export function VideoCard({
121
  src={mediaThumbnail}
122
  className={cn(
123
  `absolute`,
124
- `aspect-video`,
125
- // `aspect-video object-cover`,
126
- `rounded-lg overflow-hidden`,
127
  mediaThumbnailReady ? `opacity-100`: 'opacity-0',
128
  `hover:opacity-0 w-full h-full top-0 z-30`,
129
  //`pointer-events-none`,
@@ -167,6 +170,7 @@ export function VideoCard({
167
  {/* TEXT BLOCK */}
168
  <div className={cn(
169
  `flex flex-row`,
 
170
  isCompact ? `w-40 lg:w-44 xl:w-51` : `space-x-4`,
171
  )}>
172
  {
@@ -192,12 +196,12 @@ export function VideoCard({
192
  )}>
193
  <h3 className={cn(
194
  `text-zinc-100 font-medium mb-0 line-clamp-2`,
195
- isCompact ? `text-2xs md:text-xs lg:text-sm mb-1.5` : `text-base`
196
  )}>{media.label}</h3>
197
  <div className={cn(
198
  `flex flex-row items-center`,
199
  `text-neutral-400 font-normal space-x-1`,
200
- isCompact ? `text-3xs md:text-2xs lg:text-xs` : `text-sm`
201
  )}>
202
  <div>{media.channel.label}</div>
203
  {isCertifiedUser(media.channel.datasetUser) ? <div><RiCheckboxCircleFill className="" /></div> : null}
@@ -206,7 +210,7 @@ export function VideoCard({
206
  <div className={cn(
207
  `flex flex-row`,
208
  `text-neutral-400 font-normal`,
209
- isCompact ? `text-2xs lg:text-xs` : `text-sm`,
210
  `space-x-1`
211
  )}>
212
  <div>{formatLargeNumber(media.numberOfViews)} views</div>
 
77
  <div
78
  className={cn(
79
  `w-full flex`,
80
+ isCompact
81
+ ? `space-x-2`
82
+ : `flex-col space-y-3`,
83
  `bg-line-900`,
84
  `cursor-pointer`,
85
  className,
 
92
  <div
93
  className={cn(
94
  `flex flex-col items-center justify-center`,
95
+ isCompact ? `` : ``
 
96
  )}
97
  >
98
  <div className={cn(
99
  `relative w-full`,
100
+ `aspect-video`,
101
+ // `aspect-video rounded-xl overflow-hidden`,
102
+ isCompact ? `w-42 h-24` : ``
103
  )}>
104
  {mediaThumbnailReady && shouldLoadMedia
105
  ? <video
 
113
  src={media.assetUrl}
114
  className={cn(
115
  `w-full h-full`,
116
+ `object-cover`,
117
+ `rounded-xl overflow-hidden aspect-video`,
118
  duration > 0 ? `opacity-100`: 'opacity-0',
119
  `transition-all duration-500`,
120
  )}
 
125
  src={mediaThumbnail}
126
  className={cn(
127
  `absolute`,
128
+ `object-cover`,
129
+ `rounded-xl overflow-hidden aspect-video`,
 
130
  mediaThumbnailReady ? `opacity-100`: 'opacity-0',
131
  `hover:opacity-0 w-full h-full top-0 z-30`,
132
  //`pointer-events-none`,
 
170
  {/* TEXT BLOCK */}
171
  <div className={cn(
172
  `flex flex-row`,
173
+ `flex-none`,
174
  isCompact ? `w-40 lg:w-44 xl:w-51` : `space-x-4`,
175
  )}>
176
  {
 
196
  )}>
197
  <h3 className={cn(
198
  `text-zinc-100 font-medium mb-0 line-clamp-2`,
199
+ isCompact ? `text-sm mb-1.5` : `text-base`
200
  )}>{media.label}</h3>
201
  <div className={cn(
202
  `flex flex-row items-center`,
203
  `text-neutral-400 font-normal space-x-1`,
204
+ isCompact ? `text-xs` : `text-sm`
205
  )}>
206
  <div>{media.channel.label}</div>
207
  {isCertifiedUser(media.channel.datasetUser) ? <div><RiCheckboxCircleFill className="" /></div> : null}
 
210
  <div className={cn(
211
  `flex flex-row`,
212
  `text-neutral-400 font-normal`,
213
+ isCompact ? `text-xs` : `text-sm`,
214
  `space-x-1`
215
  )}>
216
  <div>{formatLargeNumber(media.numberOfViews)} views</div>
src/app/interface/video-player/index.tsx CHANGED
@@ -8,9 +8,11 @@ import { VideoInfo } from "@/types"
8
 
9
  export function VideoPlayer({
10
  video,
 
11
  className = ""
12
  }: {
13
  video?: VideoInfo
 
14
  className?: string
15
  }) {
16
 
@@ -37,6 +39,9 @@ export function VideoPlayer({
37
  url: video.assetUrl,
38
  }
39
  ]}
 
 
 
40
  subtitles={[]}
41
  // poster="https://cdn.jsdelivr.net/gh/naptestdev/video-examples@master/poster.png"
42
  />
 
8
 
9
  export function VideoPlayer({
10
  video,
11
+ enableShortcuts = true,
12
  className = ""
13
  }: {
14
  video?: VideoInfo
15
+ enableShortcuts?: boolean
16
  className?: string
17
  }) {
18
 
 
39
  url: video.assetUrl,
40
  }
41
  ]}
42
+
43
+ keyboardShortcut={enableShortcuts}
44
+
45
  subtitles={[]}
46
  // poster="https://cdn.jsdelivr.net/gh/naptestdev/video-examples@master/poster.png"
47
  />
src/app/server/actions/comments.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server"
2
+
3
+ import { v4 as uuidv4 } from "uuid"
4
+
5
+ import { CommentInfo, StoredCommentInfo } from "@/types"
6
+ import { stripHtml } from "@/lib/stripHtml"
7
+ import { getCurrentUser, getUsers } from "./users"
8
+ import { redis } from "./redis"
9
+
10
+ export async function submitComment(videoId: string, rawComment: string, apiKey: string): Promise<CommentInfo> {
11
+
12
+ // trim, remove HTML, limit the length
13
+ const message = stripHtml(rawComment).trim().slice(0, 1024).trim()
14
+
15
+ if (!message) { throw new Error("comment is empty") }
16
+
17
+ const user = await getCurrentUser(apiKey)
18
+
19
+ const storedComment: StoredCommentInfo = {
20
+ id: uuidv4(),
21
+ userId: user.id,
22
+ inReplyTo: undefined, // undefined means in reply to OP
23
+ createdAt: new Date().toISOString(),
24
+ updatedAt: new Date().toISOString(),
25
+ message,
26
+ numberOfLikes: 0,
27
+ numberOfReplies: 0,
28
+ likedByOriginalPoster: false,
29
+ }
30
+
31
+ await redis.lpush(`videos:${videoId}:comments`, storedComment)
32
+
33
+ const fullComment: CommentInfo = {
34
+ ...storedComment,
35
+ userInfo: {
36
+ ...user,
37
+
38
+ // important: we erase all information about the API token!
39
+ hfApiToken: undefined,
40
+ },
41
+ }
42
+
43
+ return fullComment
44
+ }
45
+
46
+
47
+ export async function getComments(videoId: string): Promise<CommentInfo[]> {
48
+ try {
49
+ const rawList = await redis.lrange<StoredCommentInfo>(`videos:${videoId}:comments`, 0, 100)
50
+
51
+ const storedComments = Array.isArray(rawList) ? rawList : []
52
+
53
+ const usersById = await getUsers(storedComments.map(u => u.userId))
54
+
55
+ const comments: CommentInfo[] = storedComments.map(storedComment => ({
56
+ ...storedComment,
57
+ userInfo: (usersById as any)[storedComment.userId] || undefined,
58
+ }))
59
+
60
+ return comments
61
+ } catch (err) {
62
+ return []
63
+ }
64
+ }
src/app/server/actions/redis.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Redis } from "@upstash/redis"
2
+
3
+ import { redisToken, redisUrl } from "./config"
4
+
5
+ export const redis = new Redis({
6
+ url: redisUrl,
7
+ token: redisToken
8
+ })
9
+
src/app/server/actions/stats.ts CHANGED
@@ -1,17 +1,9 @@
1
  "use server"
2
 
3
- import { Redis } from "@upstash/redis"
4
-
5
  import { developerMode } from "@/app/config"
6
  import { WhoAmIUser, whoAmI } from "@/huggingface/hub/src"
7
-
8
- import { redisToken, redisUrl } from "./config"
9
  import { VideoRating } from "@/types"
10
-
11
- const redis = new Redis({
12
- url: redisUrl,
13
- token: redisToken
14
- })
15
 
16
  export async function getStatsForVideos(videoIds: string[]): Promise<Record<string, { numberOfViews: number; numberOfLikes: number; numberOfDislikes: number}>> {
17
  if (!Array.isArray(videoIds)) {
 
1
  "use server"
2
 
 
 
3
  import { developerMode } from "@/app/config"
4
  import { WhoAmIUser, whoAmI } from "@/huggingface/hub/src"
 
 
5
  import { VideoRating } from "@/types"
6
+ import { redis } from "./redis";
 
 
 
 
7
 
8
  export async function getStatsForVideos(videoIds: string[]): Promise<Record<string, { numberOfViews: number; numberOfLikes: number; numberOfDislikes: number}>> {
9
  if (!Array.isArray(videoIds)) {
src/app/server/actions/users.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server"
2
+
3
+ import { WhoAmIUser, whoAmI } from "@/huggingface/hub/src"
4
+ import { UserInfo } from "@/types"
5
+ import { adminApiKey } from "./config"
6
+ import { redis } from "./redis"
7
+
8
+ export async function getCurrentUser(apiKey: string): Promise<UserInfo> {
9
+ if (!apiKey) {
10
+ throw new Error(`the apiKey is required`)
11
+ }
12
+
13
+ const credentials = { accessToken: apiKey }
14
+
15
+ const huggingFaceUser = await whoAmI({ credentials }) as unknown as WhoAmIUser
16
+
17
+ const id = huggingFaceUser.id
18
+
19
+ const user: UserInfo = {
20
+ id,
21
+ type: apiKey === adminApiKey ? "admin" : "normal",
22
+ userName: huggingFaceUser.name,
23
+ fullName: huggingFaceUser.fullname,
24
+ thumbnail: huggingFaceUser.avatarUrl,
25
+ channels: [],
26
+ hfApiToken: apiKey,
27
+ }
28
+
29
+ await redis.set(`users:${id}`, user)
30
+
31
+ return user
32
+ }
33
+
34
+ export async function getUser(id: string): Promise<UserInfo | undefined> {
35
+ const maybeUser = await redis.get<UserInfo>(id)
36
+
37
+ if (maybeUser?.id) {
38
+ const publicFacingUser: UserInfo = {
39
+ ...maybeUser,
40
+ hfApiToken: undefined, // <-- important!
41
+ }
42
+ delete publicFacingUser.hfApiToken
43
+ return publicFacingUser
44
+ }
45
+
46
+ return undefined
47
+ }
48
+
49
+ export async function getUsers(ids: string[]): Promise<Record<string, UserInfo>> {
50
+ try {
51
+ const maybeUsers = await redis.mget<UserInfo[]>(ids.map(userId => `users:${userId}`))
52
+
53
+ const usersById: Record<string, UserInfo> = {}
54
+
55
+ maybeUsers.forEach((user, index) => {
56
+ if (user?.id) {
57
+ const publicFacingUser: UserInfo = {
58
+ ...user,
59
+ hfApiToken: undefined, // <-- important!
60
+ }
61
+ delete publicFacingUser.hfApiToken
62
+ usersById[user.id] = publicFacingUser
63
+ }
64
+ })
65
+
66
+ return usersById
67
+ } catch (err) {
68
+ return {}
69
+ }
70
+ }
src/app/state/useStore.ts CHANGED
@@ -2,7 +2,7 @@
2
 
3
  import { create } from "zustand"
4
 
5
- import { ChannelInfo, VideoInfo, InterfaceDisplayMode, InterfaceView, InterfaceMenuMode, InterfaceHeaderMode } from "@/types"
6
 
7
  export const useStore = create<{
8
  displayMode: InterfaceDisplayMode
@@ -17,7 +17,10 @@ export const useStore = create<{
17
  view: InterfaceView
18
  setView: (view?: InterfaceView) => void
19
 
20
- setPathname: (patname: string) => void
 
 
 
21
 
22
  publicChannel?: ChannelInfo
23
  setPublicChannel: (setPublicChannel?: ChannelInfo) => void
@@ -46,6 +49,9 @@ export const useStore = create<{
46
  publicVideo?: VideoInfo
47
  setPublicVideo: (publicVideo?: VideoInfo) => void
48
 
 
 
 
49
  publicVideos: VideoInfo[]
50
  setPublicVideos: (publicVideos: VideoInfo[]) => void
51
 
@@ -95,6 +101,11 @@ export const useStore = create<{
95
  set({ view: routes[pathname] || "not_found" })
96
  },
97
 
 
 
 
 
 
98
  headerMode: "normal",
99
  setHeaderMode: (headerMode: InterfaceHeaderMode) => {
100
  set({ headerMode })
@@ -154,6 +165,11 @@ export const useStore = create<{
154
  set({ publicVideo })
155
  },
156
 
 
 
 
 
 
157
  publicVideos: [],
158
  setPublicVideos: (publicVideos: VideoInfo[] = []) => {
159
  set({
 
2
 
3
  import { create } from "zustand"
4
 
5
+ import { ChannelInfo, VideoInfo, InterfaceDisplayMode, InterfaceView, InterfaceMenuMode, InterfaceHeaderMode, CommentInfo, UserInfo } from "@/types"
6
 
7
  export const useStore = create<{
8
  displayMode: InterfaceDisplayMode
 
17
  view: InterfaceView
18
  setView: (view?: InterfaceView) => void
19
 
20
+ setPathname: (pathname: string) => void
21
+
22
+ currentUser?: UserInfo
23
+ setCurrentUser: (currentUser?: UserInfo) => void
24
 
25
  publicChannel?: ChannelInfo
26
  setPublicChannel: (setPublicChannel?: ChannelInfo) => void
 
49
  publicVideo?: VideoInfo
50
  setPublicVideo: (publicVideo?: VideoInfo) => void
51
 
52
+ publicComments: CommentInfo[]
53
+ setPublicComments: (publicComment: CommentInfo[]) => void
54
+
55
  publicVideos: VideoInfo[]
56
  setPublicVideos: (publicVideos: VideoInfo[]) => void
57
 
 
101
  set({ view: routes[pathname] || "not_found" })
102
  },
103
 
104
+ currentUser: undefined,
105
+ setCurrentUser: (currentUser?: UserInfo) => {
106
+ set({ currentUser })
107
+ },
108
+
109
  headerMode: "normal",
110
  setHeaderMode: (headerMode: InterfaceHeaderMode) => {
111
  set({ headerMode })
 
165
  set({ publicVideo })
166
  },
167
 
168
+ publicComments: [],
169
+ setPublicComments: (publicComments: CommentInfo[]) => {
170
+ set({ publicComments })
171
+ },
172
+
173
  publicVideos: [],
174
  setPublicVideos: (publicVideos: VideoInfo[] = []) => {
175
  set({
src/app/state/userCurrentUser.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useTransition } from "react"
2
+
3
+ import { UserInfo } from "@/types"
4
+
5
+ import { useStore } from "./useStore"
6
+ import { useLocalStorage } from "usehooks-ts"
7
+ import { localStorageKeys } from "./localStorageKeys"
8
+ import { defaultSettings } from "./defaultSettings"
9
+ import { getCurrentUser } from "../server/actions/users"
10
+
11
+ export function useCurrentUser(): UserInfo | undefined {
12
+ const [_pending, startTransition] = useTransition()
13
+
14
+ const currentUser = useStore(s => s.currentUser)
15
+ const setCurrentUser = useStore(s => s.setCurrentUser)
16
+
17
+ const [huggingfaceApiKey] = useLocalStorage<string>(
18
+ localStorageKeys.huggingfaceApiKey,
19
+ defaultSettings.huggingfaceApiKey
20
+ )
21
+ useEffect(() => {
22
+ startTransition(async () => {
23
+
24
+ // no key
25
+ if (!huggingfaceApiKey) {
26
+ setCurrentUser(undefined)
27
+ return
28
+ }
29
+
30
+ // already logged-in
31
+ if (currentUser?.id) {
32
+ return
33
+ }
34
+ try {
35
+
36
+ const user = await getCurrentUser(huggingfaceApiKey)
37
+
38
+ setCurrentUser(user)
39
+ } catch (err) {
40
+ console.error("failed to log in:", err)
41
+ setCurrentUser(undefined)
42
+ }
43
+ })
44
+
45
+ }, [huggingfaceApiKey, currentUser?.id])
46
+
47
+ return currentUser
48
+ }
src/app/views/public-video-view/index.tsx CHANGED
@@ -22,9 +22,36 @@ import { LikeButton } from "@/app/interface/like-button"
22
 
23
  import { ReportModal } from "../report-modal"
24
  import { formatLargeNumber } from "@/lib/formatLargeNumber"
 
 
 
 
 
 
 
25
 
26
  export function PublicVideoView() {
27
  const [_pending, startTransition] = useTransition()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const video = useStore(s => s.publicVideo)
29
 
30
  const videoId = `${video?.id || ""}`
@@ -34,6 +61,10 @@ export function PublicVideoView() {
34
  const [channelThumbnail, setChannelThumbnail] = useState(`${video?.channel.thumbnail || ""}`)
35
  const setPublicVideo = useStore(s => s.setPublicVideo)
36
 
 
 
 
 
37
  // we inject the current videoId in the URL, if it's not already present
38
  // this is a hack for Hugging Face iframes
39
  useEffect(() => {
@@ -62,12 +93,8 @@ export function PublicVideoView() {
62
 
63
 
64
  const handleBadChannelThumbnail = () => {
65
- try {
66
- if (channelThumbnail) {
67
- setChannelThumbnail("")
68
- }
69
- } catch (err) {
70
-
71
  }
72
  }
73
 
@@ -86,28 +113,65 @@ export function PublicVideoView() {
86
 
87
  }, [video?.id])
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  if (!video) { return null }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  return (
92
  <div className={cn(
93
  `w-full`,
94
- `flex flex-row`
95
  )}>
96
  <div className={cn(
97
  `flex-grow`,
98
  `flex flex-col`,
99
  `transition-all duration-200 ease-in-out`,
100
- `px-2 sm:px-0`
101
  )}>
102
  {/** VIDEO PLAYER - HORIZONTAL */}
103
  <VideoPlayer
104
  video={video}
 
105
  className="mb-4"
106
  />
107
 
108
  {/** VIDEO TITLE - HORIZONTAL */}
109
  <div className={cn(
110
- `flex flew-row space-x-2`,
111
  `transition-all duration-200 ease-in-out`,
112
  `text-lg lg:text-xl text-zinc-100 font-medium mb-0 line-clamp-2`,
113
  `mb-2`,
@@ -133,7 +197,7 @@ export function PublicVideoView() {
133
  `transition-all duration-200 ease-in-out`,
134
  `items-start xl:items-center`,
135
  `justify-between`,
136
- `mb-4`,
137
  )}>
138
  {/** LEFT PART OF THE TOOLBAR */}
139
  <div className={cn(
@@ -217,17 +281,19 @@ export function PublicVideoView() {
217
  <CopyToClipboard
218
  text={`https://jbilcke-hf-ai-tube.hf.space/watch?v=${video.id}`}
219
  onCopy={() => setCopied(true)}>
220
- <div className={actionButtonClassName}>
 
 
 
221
  <div className="flex items-center justify-center">
222
  {
223
- copied ? <LuCopyCheck className="w-4 h-4" />
224
- : <PiShareFatLight className="w-5 h-5" />
225
  }
226
  </div>
227
- <div>
228
- {
229
- copied ? "Link copied!" : "Share video"
230
- }</div>
231
  </div>
232
  </CopyToClipboard>
233
  </div>
@@ -241,8 +307,13 @@ export function PublicVideoView() {
241
  : "https://huggingface.co/hotshotco/Hotshot-XL"
242
  }
243
  >
244
- <BiCameraMovie />
245
- <span>Made with {video.model}</span>
 
 
 
 
 
246
  </ActionButton>
247
 
248
  <ActionButton
@@ -256,8 +327,10 @@ export function PublicVideoView() {
256
  }.md`
257
  }
258
  >
259
- <LuScrollText />
260
- <span>See prompt</span>
 
 
261
  </ActionButton>
262
 
263
  <ReportModal video={video} />
@@ -274,18 +347,146 @@ export function PublicVideoView() {
274
  `bg-neutral-700/50`,
275
  `text-sm text-zinc-100`,
276
  )}>
 
 
277
  <div className="flex flex-row space-x-2 font-medium mb-1">
278
  <div>{formatLargeNumber(video.numberOfViews)} views</div>
279
  <div>{formatTimeAgo(video.updatedAt).replace("about ", "")}</div>
280
  </div>
281
  <p>{video.description}</p>
282
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  </div>
 
284
  <div className={cn(
285
- `w-40 sm:w-56 md:w-64 lg:w-72 xl:w-[450px]`,
 
 
 
 
 
 
286
  `transition-all duration-200 ease-in-out`,
287
- `hidden sm:flex flex-col`,
288
- `pl-5 pr-1 sm:pr-2 md:pr-3 lg:pr-4 xl:pr-6 2xl:pr-8`,
289
  )}>
290
  <RecommendedVideos video={video} />
291
  </div>
 
22
 
23
  import { ReportModal } from "../report-modal"
24
  import { formatLargeNumber } from "@/lib/formatLargeNumber"
25
+ import { CommentList } from "@/app/interface/comment-list"
26
+ import { Input } from "@/components/ui/input"
27
+ import useLocalStorage from "usehooks-ts/dist/esm/useLocalStorage/useLocalStorage"
28
+ import { localStorageKeys } from "@/app/state/localStorageKeys"
29
+ import { defaultSettings } from "@/app/state/defaultSettings"
30
+ import { getComments, submitComment } from "@/app/server/actions/comments"
31
+ import { useCurrentUser } from "@/app/state/userCurrentUser"
32
 
33
  export function PublicVideoView() {
34
  const [_pending, startTransition] = useTransition()
35
+
36
+ const [commentDraft, setCommentDraft] = useState("")
37
+ const [isCommenting, setCommenting] = useState(false)
38
+ const [isFocusedOnInput, setFocusedOnInput] = useState(false)
39
+
40
+ const currentUser = useCurrentUser()
41
+
42
+ const [userThumbnail, setUserThumbnail] = useState("")
43
+
44
+ useEffect(() => {
45
+ setUserThumbnail(currentUser?.thumbnail || "")
46
+
47
+ }, [currentUser?.thumbnail])
48
+
49
+ const handleBadUserThumbnail = () => {
50
+ if (userThumbnail) {
51
+ setUserThumbnail("")
52
+ }
53
+ }
54
+
55
  const video = useStore(s => s.publicVideo)
56
 
57
  const videoId = `${video?.id || ""}`
 
61
  const [channelThumbnail, setChannelThumbnail] = useState(`${video?.channel.thumbnail || ""}`)
62
  const setPublicVideo = useStore(s => s.setPublicVideo)
63
 
64
+ const publicComments = useStore(s => s.publicComments)
65
+
66
+ const setPublicComments = useStore(s => s.setPublicComments)
67
+
68
  // we inject the current videoId in the URL, if it's not already present
69
  // this is a hack for Hugging Face iframes
70
  useEffect(() => {
 
93
 
94
 
95
  const handleBadChannelThumbnail = () => {
96
+ if (channelThumbnail) {
97
+ setChannelThumbnail("")
 
 
 
 
98
  }
99
  }
100
 
 
113
 
114
  }, [video?.id])
115
 
116
+
117
+ useEffect(() => {
118
+ startTransition(async () => {
119
+ if (!video || !video.id) {
120
+ return
121
+ }
122
+ const comments = await getComments(videoId)
123
+ setPublicComments(comments)
124
+ })
125
+
126
+ }, [video?.id])
127
+
128
+ const [huggingfaceApiKey] = useLocalStorage<string>(
129
+ localStorageKeys.huggingfaceApiKey,
130
+ defaultSettings.huggingfaceApiKey
131
+ )
132
+
133
  if (!video) { return null }
134
 
135
+ const handleSubmitComment = () => {
136
+
137
+ startTransition(async () => {
138
+ if (!commentDraft || !huggingfaceApiKey || !videoId) { return }
139
+
140
+ const limitedSizeComment = commentDraft.trim().slice(0, 1024).trim()
141
+
142
+ const comment = await submitComment(video.id, limitedSizeComment, huggingfaceApiKey)
143
+
144
+ setPublicComments(
145
+ [comment].concat(publicComments)
146
+ )
147
+
148
+ setCommentDraft("")
149
+ setFocusedOnInput(false)
150
+ setCommenting(false)
151
+ })
152
+ }
153
+
154
  return (
155
  <div className={cn(
156
  `w-full`,
157
+ `flex flex-col lg:flex-row`
158
  )}>
159
  <div className={cn(
160
  `flex-grow`,
161
  `flex flex-col`,
162
  `transition-all duration-200 ease-in-out`,
163
+ `px-2 xl:px-0`
164
  )}>
165
  {/** VIDEO PLAYER - HORIZONTAL */}
166
  <VideoPlayer
167
  video={video}
168
+ enableShortcuts={!isFocusedOnInput}
169
  className="mb-4"
170
  />
171
 
172
  {/** VIDEO TITLE - HORIZONTAL */}
173
  <div className={cn(
174
+ `flex flex-row space-x-2`,
175
  `transition-all duration-200 ease-in-out`,
176
  `text-lg lg:text-xl text-zinc-100 font-medium mb-0 line-clamp-2`,
177
  `mb-2`,
 
197
  `transition-all duration-200 ease-in-out`,
198
  `items-start xl:items-center`,
199
  `justify-between`,
200
+ `mb-2 lg:mb-3`,
201
  )}>
202
  {/** LEFT PART OF THE TOOLBAR */}
203
  <div className={cn(
 
281
  <CopyToClipboard
282
  text={`https://jbilcke-hf-ai-tube.hf.space/watch?v=${video.id}`}
283
  onCopy={() => setCopied(true)}>
284
+ <div className={cn(
285
+ actionButtonClassName,
286
+ `bg-neutral-700/50 hover:bg-neutral-700/90 text-zinc-100`
287
+ )}>
288
  <div className="flex items-center justify-center">
289
  {
290
+ copied ? <LuCopyCheck className="w-5 h-5" />
291
+ : <PiShareFatLight className="w-6 h-6" />
292
  }
293
  </div>
294
+ <span>
295
+ {copied ? "Copied!" : "Share"}
296
+ </span>
 
297
  </div>
298
  </CopyToClipboard>
299
  </div>
 
307
  : "https://huggingface.co/hotshotco/Hotshot-XL"
308
  }
309
  >
310
+ <BiCameraMovie className="w-5 h-5" />
311
+ <span className="hidden 2xl:inline">
312
+ Made with {video.model}
313
+ </span>
314
+ <span className="inline 2xl:hidden">
315
+ {video.model}
316
+ </span>
317
  </ActionButton>
318
 
319
  <ActionButton
 
327
  }.md`
328
  }
329
  >
330
+ <LuScrollText className="w-5 h-5" />
331
+ <span>
332
+ Source
333
+ </span>
334
  </ActionButton>
335
 
336
  <ReportModal video={video} />
 
347
  `bg-neutral-700/50`,
348
  `text-sm text-zinc-100`,
349
  )}>
350
+
351
+ {/* DESCRIPTION BLOCK */}
352
  <div className="flex flex-row space-x-2 font-medium mb-1">
353
  <div>{formatLargeNumber(video.numberOfViews)} views</div>
354
  <div>{formatTimeAgo(video.updatedAt).replace("about ", "")}</div>
355
  </div>
356
  <p>{video.description}</p>
357
  </div>
358
+
359
+ {/* COMMENTS */}
360
+ <div className={cn(
361
+ `flex-col font-medium mb-1 py-6`,
362
+ )}>
363
+
364
+ <div className="flex flex-row text-xl text-zinc-100 w-full mb-4">
365
+ {Number(publicComments?.length || 0).toLocaleString()} Comment{
366
+ Number(publicComments?.length || 0) === 1 ? '' : 's'
367
+ }
368
+ </div>
369
+
370
+ {/* COMMENT INPUT BLOCK - HORIZONTAL */}
371
+ {currentUser && <div className="flex flex-row w-full">
372
+
373
+ {/* AVATAR */}
374
+ <div
375
+ // className="flex flex-col w-10 pr-13 overflow-hidden"
376
+ className="flex flex-none flex-col w-10 pr-13 overflow-hidden">
377
+ {
378
+ userThumbnail ?
379
+ <div className="flex w-9 rounded-full overflow-hidden">
380
+ <img
381
+ src={userThumbnail}
382
+ onError={handleBadUserThumbnail}
383
+ />
384
+ </div>
385
+ : <DefaultAvatar
386
+ username={currentUser?.userName}
387
+ bgColor="#fde047"
388
+ textColor="#1c1917"
389
+ width={36}
390
+ roundShape
391
+ />}
392
+ </div>
393
+
394
+ {/* COMMENT INPUTS AND BUTTONS - VERTICAL */}
395
+ <div className="flex flex-col flex-grow">
396
+ <Input
397
+ placeholder="Add a comment.."
398
+ type="text"
399
+ className={cn(
400
+ `w-full`,
401
+ `rounded-none`,
402
+ `border-l-transparent border-r-transparent border-t-transparent dark:border-l-transparent dark:border-r-transparent dark:border-t-transparent`,
403
+ `border-b border-b-zinc-600 dark:border-b dark:border-b-zinc-600`,
404
+ `hover:pt-[2px] hover:pb-[1px] hover:border-b-2 hover:border-b-zinc-200 dark:hover:border-b-2 dark:hover:border-b-zinc-200`,
405
+
406
+ `outline-transparent ring-transparent ring-offset-transparent`,
407
+ `dark:outline-transparent dark:ring-transparent dark:ring-offset-transparent`,
408
+ `focus-visible:outline-transparent focus-visible:ring-transparent focus-visible:ring-offset-transparent`,
409
+ `dark:focus-visible:outline-transparent dark:focus-visible:ring-transparent dark:focus-visible:ring-offset-transparent`,
410
+
411
+ `font-normal`,
412
+ `pl-0 h-8`,
413
+
414
+ `mb-3`
415
+ )}
416
+ onChange={(x) => {
417
+ if (!isFocusedOnInput) {
418
+ setFocusedOnInput(true)
419
+ }
420
+ if (!isCommenting) {
421
+ setCommenting(true)
422
+ }
423
+ setCommentDraft(x.target.value)
424
+ }}
425
+ value={commentDraft}
426
+ onFocus={() => {
427
+ if (!isFocusedOnInput) {
428
+ setFocusedOnInput(true)
429
+ }
430
+ if (!isCommenting) {
431
+ setCommenting(true)
432
+ }
433
+ }}
434
+
435
+ onBlur={() => {
436
+ setFocusedOnInput(false)
437
+ }}
438
+ onKeyDown={({ key }) => {
439
+ if (key === 'Enter') {
440
+ handleSubmitComment()
441
+ } else {
442
+ if (!isFocusedOnInput) {
443
+ setFocusedOnInput(true)
444
+ }
445
+ if (!isCommenting) {
446
+ setCommenting(true)
447
+ }
448
+ }
449
+ }}
450
+ />
451
+
452
+ <div className={cn(
453
+ `flex-row space-x-3 w-full justify-end`,
454
+ isCommenting ? `flex` : `hidden`
455
+ )}>
456
+ <div className="flex flex-row space-x-3">
457
+ <ActionButton
458
+ variant="ghost"
459
+ onClick={() => {
460
+ setCommentDraft("")
461
+ setCommenting(false)
462
+ setFocusedOnInput(false)
463
+ }}
464
+ >Cancel</ActionButton>
465
+ <ActionButton
466
+ variant={commentDraft ? "primary" : "secondary"}
467
+ onClick={handleSubmitComment}
468
+ >Comment</ActionButton>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </div>}
473
+
474
+ <CommentList
475
+ comments={publicComments}
476
+ />
477
+ </div>
478
+
479
  </div>
480
+
481
  <div className={cn(
482
+
483
+ // this one is very important to make sure the right panel is not compressed
484
+ `flex flex-col`,
485
+ `flex-none`,
486
+ `pl-2 lg:pl-4 lg:pr-2`,
487
+
488
+ `w-full md:w-[360px] lg:w-[400px] xl:w-[450px]`,
489
  `transition-all duration-200 ease-in-out`,
 
 
490
  )}>
491
  <RecommendedVideos video={video} />
492
  </div>
src/app/views/report-modal/index.tsx CHANGED
@@ -27,7 +27,7 @@ export function ReportModal({
27
  }}>
28
  <DialogTrigger asChild>
29
  <ActionButton onClick={() => setOpen(true)}>
30
- <LuShieldAlert className="w-4 h-4" />
31
  <span>Report</span>
32
  </ActionButton>
33
  </DialogTrigger>
 
27
  }}>
28
  <DialogTrigger asChild>
29
  <ActionButton onClick={() => setOpen(true)}>
30
+ <LuShieldAlert className="w-5 h-5" />
31
  <span>Report</span>
32
  </ActionButton>
33
  </DialogTrigger>
src/lib/stripHtml.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function stripHtml(input: string) {
2
+ try {
3
+ return (
4
+ `${input || ""}`
5
+ .replace(/<style[^>]*>.*<\/style>/g, '')
6
+ // Remove script tags and content
7
+ .replace(/<script[^>]*>.*<\/script>/g, '')
8
+ // Remove all opening, closing and orphan HTML tags
9
+ .replace(/<[^>]+>/g, '')
10
+ // Remove leading spaces and repeated CR/LF
11
+ .replace(/([\r\n]+ +)+/g, '')
12
+ )
13
+ } catch (err) {
14
+ return ""
15
+ }
16
+ }
src/types.ts CHANGED
@@ -506,32 +506,31 @@ export type CollectionInfo = {
506
  items: Array<VideoInfo>[]
507
  }
508
 
509
- export type PublicUserInfo = {
510
  id: string
511
 
512
- type: "normal" | "admin"
513
 
514
  userName: string
515
 
516
- firstName: string
517
-
518
- lastName: string
519
-
520
  thumbnail: string
521
 
522
  channels: ChannelInfo[]
523
- }
524
-
525
- export type PrivateUserInfo = PublicUserInfo & {
526
 
527
- // the Hugging Face API token is confidential!
528
- hfApiToken: string
 
529
  }
530
 
531
- export type VideoComment = {
532
  id: string
533
 
534
- user: PublicUserInfo
 
 
 
535
 
536
  // if the video comment is in response to another comment,
537
  // then "inReplyTo" will contain the other video comment id
@@ -542,12 +541,17 @@ export type VideoComment = {
542
  message: string
543
 
544
  // how many likes did the comment receive
545
- nbLikes: number
546
 
547
- // if the comment was appreciated by the video owner
548
- appreciated: number
 
 
 
549
  }
550
 
 
 
551
  export type VideoGenerationModel =
552
  | "HotshotXL"
553
  | "SVD"
 
506
  items: Array<VideoInfo>[]
507
  }
508
 
509
+ export type UserInfo = {
510
  id: string
511
 
512
+ type: "creator" | "normal" | "admin"
513
 
514
  userName: string
515
 
516
+ fullName: string
517
+
 
 
518
  thumbnail: string
519
 
520
  channels: ChannelInfo[]
 
 
 
521
 
522
+ // the Hugging Face API token is confidential,
523
+ // and will only be available for the current user
524
+ hfApiToken?: string
525
  }
526
 
527
+ export type CommentInfo = {
528
  id: string
529
 
530
+ userId: string
531
+
532
+ // only populated when rendering
533
+ userInfo?: UserInfo
534
 
535
  // if the video comment is in response to another comment,
536
  // then "inReplyTo" will contain the other video comment id
 
541
  message: string
542
 
543
  // how many likes did the comment receive
544
+ numberOfLikes: number
545
 
546
+ // how many replies did the comment receive
547
+ numberOfReplies: number
548
+
549
+ // if the comment was appreciated by the original content poster
550
+ likedByOriginalPoster: boolean
551
  }
552
 
553
+ export type StoredCommentInfo = Omit<CommentInfo, "userInfo">
554
+
555
  export type VideoGenerationModel =
556
  | "HotshotXL"
557
  | "SVD"
tailwind.config.js CHANGED
@@ -46,6 +46,9 @@ module.exports = {
46
  screens: {
47
  'print': { 'raw': 'print' },
48
  },
 
 
 
49
  height: {
50
  '6.5': '1.625rem', // 26px
51
  7: '1.75rem', // 28px
 
46
  screens: {
47
  'print': { 'raw': 'print' },
48
  },
49
+ spacing: {
50
+ '13': '3.25rem', // 52px
51
+ },
52
  height: {
53
  '6.5': '1.625rem', // 26px
54
  7: '1.75rem', // 28px