Add a new "test-jazz-stream" task to flow.toml for testing live stream recording flow, including setup instructions and verification steps.

Update CommentBox component to handle image uploads with validation, preview, and progress tracking, and to manage comments with Jazz container initialization.
This commit is contained in:
Nikita
2025-12-25 05:04:43 -08:00
parent 15432a69b5
commit 9c90b7db8d
9 changed files with 1395 additions and 93 deletions

View File

@@ -1,17 +1,14 @@
import { useState, useEffect, useRef } from "react"
import { Send, LogIn } from "lucide-react"
import { Send, LogIn, ImagePlus, X } from "lucide-react"
import { authClient } from "@/lib/auth-client"
type Comment = {
id: string
user_id: string
user_name: string
user_email: string
content: string
created_at: string
}
type AuthStep = "idle" | "email" | "otp"
import { useAccount, useCoState } from "jazz-tools/react"
import { Group, co, FileStream } from "jazz-tools"
import {
StreamComment,
StreamCommentList,
StreamCommentsContainer,
ViewerAccount,
} from "@/lib/jazz/schema"
interface CommentBoxProps {
username: string
@@ -19,13 +16,18 @@ interface CommentBoxProps {
export function CommentBox({ username }: CommentBoxProps) {
const { data: session, isPending: sessionLoading } = authClient.useSession()
const [comments, setComments] = useState<Comment[]>([])
const me = useAccount(ViewerAccount)
const [containerId, setContainerId] = useState<string | undefined>(undefined)
const container = useCoState(StreamCommentsContainer, containerId, { resolve: { comments: true } })
const [newComment, setNewComment] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [selectedImage, setSelectedImage] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
// Auth state
const [authStep, setAuthStep] = useState<AuthStep>("idle")
const [authStep, setAuthStep] = useState<"idle" | "email" | "otp">("idle")
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
const [authLoading, setAuthLoading] = useState(false)
@@ -34,6 +36,7 @@ export function CommentBox({ username }: CommentBoxProps) {
const commentsEndRef = useRef<HTMLDivElement>(null)
const emailInputRef = useRef<HTMLInputElement>(null)
const otpInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Focus inputs when auth step changes
useEffect(() => {
@@ -44,32 +47,72 @@ export function CommentBox({ username }: CommentBoxProps) {
}
}, [authStep])
// Fetch comments
// Initialize or load the comments container for this stream
useEffect(() => {
const fetchComments = async () => {
if (!me?.$isLoaded) return
const initContainer = async () => {
try {
const res = await fetch(`/api/stream-comments?username=${username}`)
if (res.ok) {
const data = (await res.json()) as { comments?: Comment[] }
setComments(data.comments || [])
}
const containerUID = { stream: username, origin: "linsa.io", type: "comments" }
// Create a group writable by everyone
const group = Group.create({ owner: me })
group.addMember("everyone", "writer")
// Upsert the container
const result = await StreamCommentsContainer.upsertUnique({
value: { comments: StreamCommentList.create([], { owner: group }) },
unique: containerUID,
owner: group,
})
setContainerId(result.$jazz.id)
} catch (err) {
console.error("Failed to fetch comments:", err)
} finally {
setIsLoading(false)
console.error("Failed to init comments container:", err)
}
}
fetchComments()
const interval = setInterval(fetchComments, 5000) // Poll every 5 seconds
initContainer()
}, [me?.$isLoaded, username])
return () => clearInterval(interval)
}, [username])
// Get comments from the container (only when loaded)
const comments = container?.$isLoaded ? container.comments : undefined
// Scroll to bottom when new comments arrive
useEffect(() => {
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [comments])
}, [comments?.length])
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
if (!file.type.startsWith("image/")) {
alert("Please select an image file")
return
}
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
alert("Image must be less than 10MB")
return
}
setSelectedImage(file)
setImagePreview(URL.createObjectURL(file))
}
const clearSelectedImage = () => {
setSelectedImage(null)
if (imagePreview) {
URL.revokeObjectURL(imagePreview)
setImagePreview(null)
}
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleSendOTP = async (e: React.FormEvent) => {
e.preventDefault()
@@ -112,7 +155,6 @@ export function CommentBox({ username }: CommentBoxProps) {
if (result.error) {
setAuthError(result.error.message || "Invalid code")
} else {
// Success - close auth form
setAuthStep("idle")
setEmail("")
setOtp("")
@@ -126,24 +168,47 @@ export function CommentBox({ username }: CommentBoxProps) {
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim() || !session?.user) return
const commentsList = container?.$isLoaded ? container.comments : undefined
if ((!newComment.trim() && !selectedImage) || !session?.user || !me?.$isLoaded || !commentsList?.$isLoaded) return
setIsSubmitting(true)
try {
const res = await fetch("/api/stream-comments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
content: newComment.trim(),
}),
})
setUploadProgress(0)
if (res.ok) {
const data = (await res.json()) as { comment: Comment }
setComments((prev) => [...prev, data.comment])
setNewComment("")
try {
// Create a group for the comment
const group = Group.create({ owner: me })
group.addMember("everyone", "reader")
// Upload image if selected
let imageStream = undefined
if (selectedImage) {
imageStream = await co.fileStream().createFromBlob(selectedImage, {
owner: group,
onProgress: (progress) => {
setUploadProgress(Math.round(progress * 100))
},
})
}
// Create the comment
const comment = StreamComment.create(
{
content: newComment.trim(),
userName: session.user.name || session.user.email?.split("@")[0] || "Anonymous",
userId: session.user.id || null,
image: imageStream,
createdAt: Date.now(),
},
{ owner: group }
)
// Add to list
;(commentsList as unknown as { push: (item: typeof comment) => void }).push(comment)
// Clear form
setNewComment("")
clearSelectedImage()
setUploadProgress(0)
} catch (err) {
console.error("Failed to post comment:", err)
} finally {
@@ -151,8 +216,8 @@ export function CommentBox({ username }: CommentBoxProps) {
}
}
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
@@ -167,35 +232,42 @@ export function CommentBox({ username }: CommentBoxProps) {
{/* Comments list */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{isLoading ? (
{!comments ? (
<div className="text-center text-white/40 text-sm py-4">Loading...</div>
) : comments.length === 0 ? (
<div className="text-center text-white/40 text-sm py-4">
No messages yet. Be the first to say hi!
</div>
) : (
comments.map((comment) => (
<div key={comment.id} className="group">
<div className="flex items-start gap-2">
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-white/70">
{comment.user_name?.charAt(0).toUpperCase() || "?"}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-white/60 truncate">
{comment.user_name || "Anonymous"}
</span>
<span className="text-[10px] text-white/30">
{formatTime(comment.created_at)}
comments.map((comment, index: number) => {
if (!comment?.$isLoaded) return null
return (
<div key={comment.$jazz.id || index} className="group">
<div className="flex items-start gap-2">
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<span className="text-xs font-medium text-white/70">
{comment.userName?.charAt(0).toUpperCase() || "?"}
</span>
</div>
<p className="text-sm text-white/90 break-words">{comment.content}</p>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-white/60 truncate">
{comment.userName || "Anonymous"}
</span>
<span className="text-[10px] text-white/30">
{formatTime(comment.createdAt)}
</span>
</div>
{comment.content && (
<p className="text-sm text-white/90 break-words">{comment.content}</p>
)}
{comment.image?.$isLoaded && <CommentImage image={comment.image} />}
</div>
</div>
</div>
</div>
))
)
})
)}
<div ref={commentsEndRef} />
</div>
@@ -205,23 +277,68 @@ export function CommentBox({ username }: CommentBoxProps) {
{sessionLoading ? (
<div className="text-center text-white/40 text-sm py-2">Loading...</div>
) : isAuthenticated ? (
<form onSubmit={handleSubmitComment} className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Send a message..."
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
disabled={isSubmitting}
/>
<button
type="submit"
disabled={!newComment.trim() || isSubmitting}
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={16} />
</button>
</form>
<div className="space-y-2">
{/* Image preview */}
{imagePreview && (
<div className="relative inline-block">
<img
src={imagePreview}
alt="Preview"
className="max-h-24 rounded-lg border border-white/20"
/>
<button
type="button"
onClick={clearSelectedImage}
className="absolute -top-2 -right-2 p-1 bg-black/80 rounded-full text-white/70 hover:text-white"
>
<X size={14} />
</button>
</div>
)}
{/* Upload progress */}
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="w-full bg-white/10 rounded-full h-1">
<div
className="bg-teal-500 h-1 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<form onSubmit={handleSubmitComment} className="flex gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleImageSelect}
accept="image/*"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-2 py-2 text-white/50 hover:text-white/80 transition-colors"
disabled={isSubmitting}
>
<ImagePlus size={18} />
</button>
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Send a message..."
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
disabled={isSubmitting}
/>
<button
type="submit"
disabled={(!newComment.trim() && !selectedImage) || isSubmitting}
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={16} />
</button>
</form>
</div>
) : authStep === "idle" ? (
<button
onClick={() => setAuthStep("email")}
@@ -241,9 +358,7 @@ export function CommentBox({ username }: CommentBoxProps) {
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
/>
{authError && (
<p className="text-xs text-red-400">{authError}</p>
)}
{authError && <p className="text-xs text-red-400">{authError}</p>}
<div className="flex gap-2">
<button
type="button"
@@ -266,9 +381,7 @@ export function CommentBox({ username }: CommentBoxProps) {
</form>
) : (
<form onSubmit={handleVerifyOTP} className="space-y-2">
<p className="text-xs text-white/60 text-center">
Code sent to {email}
</p>
<p className="text-xs text-white/60 text-center">Code sent to {email}</p>
<input
ref={otpInputRef}
type="text"
@@ -281,9 +394,7 @@ export function CommentBox({ username }: CommentBoxProps) {
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-center text-lg font-mono tracking-widest text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
/>
{authError && (
<p className="text-xs text-red-400">{authError}</p>
)}
{authError && <p className="text-xs text-red-400">{authError}</p>}
<div className="flex gap-2">
<button
type="button"
@@ -310,3 +421,40 @@ export function CommentBox({ username }: CommentBoxProps) {
</div>
)
}
// Component to display an image from a FileStream
function CommentImage({ image }: { image: FileStream }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (!image) return
try {
const blob = image.toBlob()
if (blob) {
const objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}
} catch {
// Image still loading
}
}, [image])
if (!url) {
return (
<div className="mt-2 w-32 h-24 bg-white/5 rounded-lg animate-pulse flex items-center justify-center">
<span className="text-xs text-white/30">Loading...</span>
</div>
)
}
return (
<img
src={url}
alt="Attached"
className="mt-2 max-w-full max-h-48 rounded-lg border border-white/10 cursor-pointer hover:border-white/30 transition-colors"
onClick={() => window.open(url, "_blank")}
/>
)
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useRef, useState, useCallback } from "react"
import { Play, Pause, Download, Maximize2 } from "lucide-react"
import type { StreamRecording } from "@/lib/jazz/schema"
interface StreamTimelineProps {
recording: StreamRecording
width?: number
height?: number
}
/**
* Timeline visualization for live stream recordings
* Shows horizontal timeline with real-time progress as chunks arrive
* Supports horizontal scrolling to navigate through the stream
*/
export function StreamTimeline({
recording,
width = 800,
height = 120,
}: StreamTimelineProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [scrollX, setScrollX] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [videoUrl, setVideoUrl] = useState<string | null>(null)
// Convert Jazz FileStream to blob URL for video player
useEffect(() => {
if (recording.videoFile?.$isLoaded) {
try {
const blob = recording.videoFile.toBlob()
if (blob) {
const url = URL.createObjectURL(blob)
setVideoUrl(url)
return () => URL.revokeObjectURL(url)
}
} catch (err) {
console.error("[StreamTimeline] Failed to create video URL:", err)
}
}
}, [recording.videoFile])
// Get stream metadata
const startedAt = recording.startedAt
const isLive = recording.isLive
const duration = recording.durationMs || 0
const ended = recording.endedAt !== null
// Calculate timeline metrics
const pixelsPerMs = 0.05 // 1 second = 50 pixels
const totalWidth = Math.max(width, duration * pixelsPerMs)
const visibleDuration = width / pixelsPerMs
// Draw timeline
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
// Set canvas size for retina displays
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
// Clear canvas
ctx.fillStyle = "#0a0e1a"
ctx.fillRect(0, 0, width, height)
// Draw time markers
const startMs = scrollX / pixelsPerMs
const endMs = (scrollX + width) / pixelsPerMs
ctx.strokeStyle = "#1e293b"
ctx.lineWidth = 1
ctx.font = "10px monospace"
ctx.fillStyle = "#64748b"
// Draw vertical lines every 10 seconds
const intervalMs = 10000
const firstMarker = Math.floor(startMs / intervalMs) * intervalMs
for (let ms = firstMarker; ms <= endMs; ms += intervalMs) {
const x = ms * pixelsPerMs - scrollX
if (x >= 0 && x <= width) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
ctx.stroke()
// Draw time label
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
const label = `${minutes}:${secs.toString().padStart(2, "0")}`
ctx.fillText(label, x + 4, 12)
}
}
// Draw stream progress bar
const progressWidth = duration * pixelsPerMs - scrollX
if (progressWidth > 0) {
ctx.fillStyle = isLive ? "#22c55e" : "#3b82f6"
ctx.fillRect(0, height - 20, Math.min(progressWidth, width), 8)
}
// Draw current time indicator (playhead)
if (isPlaying || currentTime > 0) {
const playheadX = currentTime * pixelsPerMs - scrollX
if (playheadX >= 0 && playheadX <= width) {
ctx.strokeStyle = "#f59e0b"
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(playheadX, 0)
ctx.lineTo(playheadX, height)
ctx.stroke()
}
}
// Draw live indicator if recording is live
if (isLive) {
ctx.fillStyle = "#ef4444"
ctx.beginPath()
ctx.arc(width - 20, 20, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = "#ffffff"
ctx.font = "11px monospace"
ctx.fillText("LIVE", width - 60, 24)
}
}, [scrollX, width, height, duration, isLive, isPlaying, currentTime, pixelsPerMs])
// Handle horizontal scrolling
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault()
const delta = e.deltaX || e.deltaY
setScrollX((prev) => {
const next = prev + delta
return Math.max(0, Math.min(next, Math.max(0, totalWidth - width)))
})
},
[totalWidth, width]
)
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener("wheel", handleWheel, { passive: false })
return () => container.removeEventListener("wheel", handleWheel)
}, [handleWheel])
// Handle click to seek
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const timeMs = (x + scrollX) / pixelsPerMs
setCurrentTime(Math.min(timeMs, duration))
if (isPlaying) {
setIsPlaying(false)
}
}
// Auto-scroll to follow live stream
useEffect(() => {
if (isLive && !isDragging) {
const targetScroll = Math.max(0, duration * pixelsPerMs - width)
setScrollX(targetScroll)
}
}, [isLive, duration, pixelsPerMs, width, isDragging])
const formatTime = (ms: number) => {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, "0")}`
}
const handleDownload = () => {
if (videoUrl) {
const a = document.createElement("a")
a.href = videoUrl
a.download = `${recording.title}.mp4`
a.click()
}
}
return (
<div className="flex flex-col gap-3 bg-[#0a0e1a] border border-white/10 rounded-xl p-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-white">{recording.title}</h3>
<p className="text-xs text-white/50">
{new Date(startedAt).toLocaleString()} · {formatTime(duration)}
{isLive && <span className="text-green-400 ml-2"> Recording</span>}
</p>
</div>
<div className="flex items-center gap-2">
{videoUrl && (
<>
<button
type="button"
onClick={() => setIsPlaying(!isPlaying)}
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button>
<button
type="button"
onClick={handleDownload}
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
>
<Download className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
{/* Timeline Canvas */}
<div
ref={containerRef}
className="relative overflow-hidden rounded-lg bg-[#0c0f18] border border-white/5"
style={{ width, height }}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<canvas
ref={canvasRef}
style={{ width, height }}
onClick={handleCanvasClick}
className="cursor-pointer"
/>
</div>
{/* Scroll indicator */}
<div className="flex items-center justify-between text-xs text-white/40">
<span>Scroll or drag to navigate</span>
<span>
{formatTime(scrollX / pixelsPerMs)} - {formatTime((scrollX + width) / pixelsPerMs)}
</span>
</div>
{/* Metadata */}
{recording.metadata && (
<div className="grid grid-cols-4 gap-2 text-xs">
{recording.metadata.width && (
<div className="text-white/50">
Resolution: {recording.metadata.width}×{recording.metadata.height}
</div>
)}
{recording.metadata.fps && (
<div className="text-white/50">FPS: {recording.metadata.fps.toFixed(1)}</div>
)}
{recording.metadata.bitrate && (
<div className="text-white/50">
Bitrate: {(recording.metadata.bitrate / 1000).toFixed(0)}kbps
</div>
)}
</div>
)}
</div>
)
}