mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
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:
@@ -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")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user