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")}
/>
)
}