Publish Container / docker (push) Successful in 13m12s
- server/routes/contact.js: import fireWebhooks (was undefined, throwing on every submission) - server/routes/analytics.js: filter admin-stats by row.at, the field recordVisitor actually sets, instead of the never-populated row.visitedAt - src/App.tsx: parseSIRef now supports chapter-spanning verse ranges (e.g. "5:15-6:2"); SI_BOOK_MAP indexes both spaced and unspaced book names so multi-word Bible Book values from the admin field resolve correctly - src/App.css / src/App.tsx: fix Episodes/Studies nav dropdown menus being unclickable — a positioning gap between the trigger and menu created a hover dead zone; menu is now flush with spacing moved into padding so hover carries through - src/components/StudySectionComments.tsx: check response status before removing a comment locally, so a rejected delete no longer appears to succeed - src/components/QASection.tsx: don't bail out of search scoring for 1-2 character queries, so short substrings like "OT" or "AI" can still match - src/AdminPage.tsx: check response status in study-user, QR code, and episode-script delete handlers before updating local state Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
interface Comment {
|
|
id: string
|
|
displayName: string
|
|
text: string
|
|
createdAt: string
|
|
}
|
|
|
|
interface Props {
|
|
studySlug: string
|
|
sectionId: string
|
|
isEnrolled: boolean
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
try {
|
|
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props) {
|
|
const [comments, setComments] = useState<Comment[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [text, setText] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [success, setSuccess] = useState(false)
|
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
const textRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
fetch(`/api/study-comments/${studySlug}/${sectionId}`)
|
|
.then(r => r.json())
|
|
.then(data => setComments(Array.isArray(data.comments) ? data.comments : []))
|
|
.catch(() => setComments([]))
|
|
.finally(() => setLoading(false))
|
|
}, [studySlug, sectionId])
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!text.trim()) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
setSuccess(false)
|
|
try {
|
|
const res = await fetch(`/api/study-comments/${studySlug}/${sectionId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: text.trim() }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setError(data.message ?? 'Failed to post comment.')
|
|
return
|
|
}
|
|
setComments(prev => [...prev, data.comment])
|
|
setText('')
|
|
setSuccess(true)
|
|
setTimeout(() => setSuccess(false), 3000)
|
|
} catch {
|
|
setError('Network error. Please try again.')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(commentId: string) {
|
|
setDeletingId(commentId)
|
|
try {
|
|
const res = await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
|
|
if (!res.ok) {
|
|
setError('Could not delete that comment.')
|
|
return
|
|
}
|
|
setComments(prev => prev.filter(c => c.id !== commentId))
|
|
} catch {
|
|
setError('Network error. Please try again.')
|
|
} finally {
|
|
setDeletingId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="study-comments">
|
|
<h3 className="study-comments-heading">Share Your Thoughts</h3>
|
|
|
|
{loading ? (
|
|
<p className="study-comments-loading">Loading comments…</p>
|
|
) : comments.length === 0 ? (
|
|
<p className="study-comments-empty">No comments yet. Be the first to share a thought!</p>
|
|
) : (
|
|
<ul className="study-comments-list">
|
|
{comments.map(comment => (
|
|
<li key={comment.id} className="study-comment">
|
|
<div className="study-comment-header">
|
|
<span className="study-comment-author">{comment.displayName}</span>
|
|
<span className="study-comment-date">{formatDate(comment.createdAt)}</span>
|
|
</div>
|
|
<p className="study-comment-text">{comment.text}</p>
|
|
<button
|
|
type="button"
|
|
className="study-comment-delete"
|
|
onClick={() => handleDelete(comment.id)}
|
|
disabled={deletingId === comment.id}
|
|
aria-label="Delete comment"
|
|
>
|
|
{deletingId === comment.id ? 'Deleting…' : 'Delete'}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{isEnrolled ? (
|
|
<form className="study-comment-form" onSubmit={handleSubmit}>
|
|
<label htmlFor="study-comment-input" className="study-comment-form-label">
|
|
Leave a comment
|
|
</label>
|
|
<textarea
|
|
id="study-comment-input"
|
|
ref={textRef}
|
|
className="study-comment-textarea"
|
|
value={text}
|
|
onChange={e => setText(e.target.value)}
|
|
placeholder="Share a thought, question, or encouragement…"
|
|
rows={3}
|
|
maxLength={2000}
|
|
disabled={submitting}
|
|
/>
|
|
<div className="study-comment-form-footer">
|
|
<span className="study-comment-char-count">{text.length}/2000</span>
|
|
{error && <span className="study-comment-error">{error}</span>}
|
|
{success && <span className="study-comment-success">Comment posted!</span>}
|
|
<button
|
|
type="submit"
|
|
className="study-comment-submit"
|
|
disabled={submitting || !text.trim()}
|
|
>
|
|
{submitting ? 'Posting…' : 'Post Comment'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<p className="study-comments-enroll-note">
|
|
<a href="/study">Enroll in this study</a> to join the discussion.
|
|
</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|