Spaces:
Sleeping
Sleeping
Julian Bilcke
commited on
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
|