Spaces:
Sleeping
Sleeping
Commit
•
38d787b
1
Parent(s):
964db57
we can now post comments
Browse files- src/app/interface/action-button/index.tsx +9 -1
- src/app/interface/comment-card/index.tsx +62 -24
- src/app/interface/comment-list/index.tsx +3 -2
- src/app/interface/like-button/generic.tsx +13 -8
- src/app/interface/media-list/index.tsx +1 -1
- src/app/interface/video-card/index.tsx +15 -11
- src/app/interface/video-player/index.tsx +5 -0
- src/app/server/actions/comments.ts +64 -0
- src/app/server/actions/redis.ts +9 -0
- src/app/server/actions/stats.ts +1 -9
- src/app/server/actions/users.ts +70 -0
- src/app/state/useStore.ts +18 -2
- src/app/state/userCurrentUser.ts +48 -0
- src/app/views/public-video-view/index.tsx +225 -24
- src/app/views/report-modal/index.tsx +1 -1
- src/lib/stripHtml.ts +16 -0
- src/types.ts +20 -16
- tailwind.config.js +3 -0
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 {
|
3 |
import { useEffect, useState } from "react"
|
4 |
import { DefaultAvatar } from "../default-avatar"
|
|
|
5 |
|
6 |
export function CommentCard({
|
7 |
comment,
|
8 |
replies = []
|
9 |
}: {
|
10 |
-
comment?:
|
11 |
-
replies:
|
12 |
}) {
|
13 |
|
14 |
-
const
|
|
|
|
|
|
|
15 |
|
16 |
useEffect(() => {
|
17 |
-
setUserThumbnail(comment?.
|
18 |
|
19 |
-
}, [comment?.
|
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-
|
|
|
42 |
|
43 |
)}>
|
44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
45 |
className={cn(
|
46 |
-
`flex flex-col items-
|
47 |
-
`
|
48 |
-
`w-26 h-26`
|
49 |
)}
|
50 |
>
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
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 {
|
5 |
import { CommentCard } from "../comment-card"
|
6 |
|
7 |
export function CommentList({
|
8 |
comments = []
|
9 |
}: {
|
10 |
-
comments:
|
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-
|
51 |
)}
|
52 |
onClick={() => {
|
53 |
try {
|
@@ -57,15 +57,17 @@ export function GenericLikeButton({
|
|
57 |
}}}
|
58 |
>
|
59 |
<div>{
|
60 |
-
isLikedByUser
|
|
|
|
|
61 |
}</div>
|
62 |
-
<div>{formatLargeNumber(
|
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-
|
69 |
)}
|
70 |
onClick={() => {
|
71 |
try {
|
@@ -74,10 +76,13 @@ export function GenericLikeButton({
|
|
74 |
|
75 |
}}}
|
76 |
>
|
|
|
77 |
<div>{
|
78 |
-
isDislikedByUser
|
|
|
|
|
79 |
}</div>
|
80 |
-
<div>{formatLargeNumber(
|
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%]"> </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
|
|
|
|
|
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 |
-
|
94 |
-
isCompact ? `w-42 h-[94px]` : `aspect-video`
|
95 |
)}
|
96 |
>
|
97 |
<div className={cn(
|
98 |
`relative w-full`,
|
99 |
-
|
|
|
|
|
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 |
-
`
|
|
|
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 |
-
`
|
125 |
-
|
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-
|
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-
|
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-
|
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: (
|
|
|
|
|
|
|
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 |
-
|
66 |
-
|
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
|
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
|
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-
|
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={
|
|
|
|
|
|
|
221 |
<div className="flex items-center justify-center">
|
222 |
{
|
223 |
-
copied ? <LuCopyCheck className="w-
|
224 |
-
: <PiShareFatLight className="w-
|
225 |
}
|
226 |
</div>
|
227 |
-
<
|
228 |
-
|
229 |
-
|
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
|
|
|
|
|
|
|
|
|
|
|
246 |
</ActionButton>
|
247 |
|
248 |
<ActionButton
|
@@ -256,8 +327,10 @@ export function PublicVideoView() {
|
|
256 |
}.md`
|
257 |
}
|
258 |
>
|
259 |
-
<LuScrollText />
|
260 |
-
<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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
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-
|
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
|
510 |
id: string
|
511 |
|
512 |
-
type: "normal" | "admin"
|
513 |
|
514 |
userName: string
|
515 |
|
516 |
-
|
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 |
-
|
|
|
529 |
}
|
530 |
|
531 |
-
export type
|
532 |
id: string
|
533 |
|
534 |
-
|
|
|
|
|
|
|
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 |
-
|
546 |
|
547 |
-
//
|
548 |
-
|
|
|
|
|
|
|
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
|