notes upgrade
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user