notes upgrade

This commit is contained in:
nmemmert
2026-06-18 13:43:17 -04:00
parent e0ed798741
commit dd43d0c3ea
9 changed files with 1591 additions and 155 deletions
+28 -2
View File
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
import type { ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
interface ContactFields {
firstName: string
lastName: string
@@ -37,9 +39,23 @@ export default function ContactForm() {
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
const { name, value } = e.target
setFields(f => ({ ...f, [name]: value }))
if (name === 'email' && fieldErrors.email) {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}
function handleEmailBlur() {
if (!fields.email) return
if (!EMAIL_REGEX.test(fields.email)) {
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
} else {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
@@ -93,7 +109,17 @@ export default function ContactForm() {
</label>
<label>
Email
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
<input
type="email"
name="email"
required
autoComplete="email"
value={fields.email}
onChange={handleChange}
onBlur={handleEmailBlur}
aria-describedby={fieldErrors.email ? 'contact-email-error' : undefined}
/>
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
</label>
<label>
Message Type
+9 -1
View File
@@ -34,6 +34,7 @@ function SpotifyLinkIcon({ href }: { href: string }) {
export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: EpisodeAudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [buffering, setBuffering] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const playTrackedRef = useRef(false)
@@ -58,18 +59,25 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
const onDurationChange = () => setDuration(audio.duration)
const onEnded = () => {
setPlaying(false)
setBuffering(false)
flushListenTime()
if (title) sendEvent('audio_completion', { title })
}
const onWaiting = () => setBuffering(true)
const onCanPlay = () => setBuffering(false)
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDurationChange)
audio.addEventListener('loadedmetadata', onDurationChange)
audio.addEventListener('ended', onEnded)
audio.addEventListener('waiting', onWaiting)
audio.addEventListener('canplay', onCanPlay)
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('durationchange', onDurationChange)
audio.removeEventListener('loadedmetadata', onDurationChange)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('waiting', onWaiting)
audio.removeEventListener('canplay', onCanPlay)
flushListenTime()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -135,7 +143,7 @@ export function EpisodeAudioPlayer({ src, title, size = 'full', spotifyUrl }: Ep
<div className="episode-audio-player__controls">
<button
className="episode-audio-player__play"
className={`episode-audio-player__play${buffering ? ' episode-audio-player__play--buffering' : ''}`}
onClick={togglePlay}
aria-label={playing ? 'Pause' : 'Play'}
>
+96
View File
@@ -0,0 +1,96 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect } from 'react'
interface NoteCardProps {
anchorLabel: string
html: string
autoSaveMsg?: string
onChange: (html: string) => void
onClose: () => void
}
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
const editor = useEditor({
extensions: [StarterKit],
content: html || '<p></p>',
onUpdate({ editor }) {
const next = editor.getHTML()
onChange(next === '<p></p>' ? '' : next)
},
})
useEffect(() => {
if (!editor || editor.isDestroyed) return
if (editor.getHTML() !== html) {
editor.commands.setContent(html || '<p></p>', false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html])
useEffect(() => () => { editor?.destroy() }, [editor])
if (!editor) return null
return (
<div className="note-card">
<div className="note-card-header">
<span className="note-card-label"> {anchorLabel}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{autoSaveMsg && <span className="note-card-autosave">{autoSaveMsg}</span>}
<button type="button" className="note-card-close" onClick={onClose} aria-label="Close note">×</button>
</div>
</div>
<div className="note-card-toolbar" role="toolbar" aria-label="Text formatting">
<button
type="button"
className={`note-tool${editor.isActive('bold') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBold().run() }}
aria-label="Bold"
title="Bold (Ctrl+B)"
><strong>B</strong></button>
<button
type="button"
className={`note-tool${editor.isActive('italic') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleItalic().run() }}
aria-label="Italic"
title="Italic (Ctrl+I)"
><em>I</em></button>
<button
type="button"
className={`note-tool${editor.isActive('bulletList') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBulletList().run() }}
aria-label="Bullet list"
title="Bullet list"
></button>
<button
type="button"
className={`note-tool${editor.isActive('orderedList') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleOrderedList().run() }}
aria-label="Numbered list"
title="Numbered list"
>1.</button>
<div className="note-tool-divider" />
<button
type="button"
className="note-tool"
onMouseDown={e => { e.preventDefault(); editor.chain().focus().undo().run() }}
disabled={!editor.can().undo()}
aria-label="Undo"
title="Undo (Ctrl+Z)"
></button>
<button
type="button"
className="note-tool"
onMouseDown={e => { e.preventDefault(); editor.chain().focus().redo().run() }}
disabled={!editor.can().redo()}
aria-label="Redo"
title="Redo (Ctrl+Y)"
></button>
</div>
<EditorContent editor={editor} className="note-card-editor" />
</div>
)
}
+9 -1
View File
@@ -698,7 +698,15 @@ export default function QASection() {
</h2>
{loading ? (
<p className="qa-no-results">Loading questions...</p>
<div className="qa-skeleton-list" aria-busy="true" aria-label="Loading questions">
{[0, 1, 2].map(i => (
<div key={i} className="qa-skeleton-card">
<div className="qa-skeleton-line qa-skeleton-line--short" />
<div className="qa-skeleton-line" />
<div className="qa-skeleton-line qa-skeleton-line--medium" />
</div>
))}
</div>
) : questions.length === 0 ? (
<div className="qa-no-results">
<p>No questions have been answered yet. <a href="/contact">Submit yours below!</a></p>