notes upgrade
This commit is contained in:
+419
-3
@@ -386,6 +386,17 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -719,6 +730,78 @@
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Shared shimmer mixin used by multiple skeleton components */
|
||||
.episode-embed-skeleton {
|
||||
width: 100%;
|
||||
height: 152px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
/* Q&A skeleton cards */
|
||||
.qa-skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.qa-skeleton-card {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.qa-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.qa-skeleton-line--short { width: 38%; }
|
||||
.qa-skeleton-line--medium { width: 68%; }
|
||||
|
||||
/* Certificate loading skeleton */
|
||||
.cert-skeleton-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.cert-skeleton-card {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
padding: 3rem 2.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cert-skeleton-line {
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.cert-skeleton-line--short { width: 40%; }
|
||||
.cert-skeleton-line--medium { width: 65%; }
|
||||
.cert-skeleton-line--title { width: 80%; height: 28px; border-radius: 8px; }
|
||||
|
||||
.platform-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -1490,6 +1573,23 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.study-lesson-announcement-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
color: #3a2800;
|
||||
opacity: 0.65;
|
||||
padding: 0.2rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.study-lesson-announcement-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-study-course-hero {
|
||||
padding: 6.5rem 0 3rem;
|
||||
background: linear-gradient(135deg, rgba(201, 168, 76, 0.08) 0%, rgba(201, 168, 76, 0.03) 40%, transparent 100%);
|
||||
@@ -2483,6 +2583,261 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Anchor note buttons (per lesson block) ────────────────────── */
|
||||
.study-class-block--anchored {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-anchor-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #3a3828;
|
||||
border-radius: 6px;
|
||||
color: #5a5440;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.note-anchor-btn:hover {
|
||||
color: #e0c070;
|
||||
border-color: rgba(224, 192, 112, 0.45);
|
||||
background: rgba(224, 192, 112, 0.06);
|
||||
}
|
||||
|
||||
.note-anchor-btn--has-note {
|
||||
color: #c9a84c;
|
||||
border-color: rgba(201, 168, 76, 0.35);
|
||||
}
|
||||
|
||||
.note-anchor-btn--open {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.1);
|
||||
border-color: rgba(224, 192, 112, 0.5);
|
||||
}
|
||||
|
||||
/* ── Sidebar anchor list ─────────────────────────────────────────── */
|
||||
.note-anchor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.note-anchor-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: transparent;
|
||||
border: 1px solid #27231b;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
color: #7a7060;
|
||||
font-size: 0.83rem;
|
||||
font-family: var(--brand-font-body);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color 150ms, border-color 150ms, background 150ms;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-anchor-list-item:hover {
|
||||
color: #f0ead8;
|
||||
border-color: #3a3828;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.note-anchor-list-item--has {
|
||||
color: #c9a84c;
|
||||
border-color: rgba(201, 168, 76, 0.3);
|
||||
}
|
||||
|
||||
.note-anchor-list-item--open {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.07);
|
||||
border-color: rgba(224, 192, 112, 0.45);
|
||||
}
|
||||
|
||||
.note-anchor-dot {
|
||||
font-size: 0.45rem;
|
||||
color: #c9a84c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── NoteCard (WYSIWYG inline editor) ────────────────────────────── */
|
||||
.note-card {
|
||||
margin-top: 1.25rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(224, 192, 112, 0.22);
|
||||
background: #14130f;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.note-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #27231b;
|
||||
}
|
||||
|
||||
.note-card-label {
|
||||
color: #c9a84c;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: var(--brand-font-body);
|
||||
}
|
||||
|
||||
.note-card-autosave {
|
||||
color: #7a9c6a;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--brand-font-body);
|
||||
}
|
||||
|
||||
.note-card-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6a6050;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
.note-card-close:hover {
|
||||
color: #e0c070;
|
||||
}
|
||||
|
||||
.note-card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-bottom: 1px solid #27231b;
|
||||
}
|
||||
|
||||
.note-tool {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: #8a8070;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
font-family: var(--brand-font-body);
|
||||
cursor: pointer;
|
||||
transition: color 150ms, background 150ms, border-color 150ms;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.note-tool:hover:not(:disabled) {
|
||||
color: #f0ead8;
|
||||
background: #1f1d19;
|
||||
}
|
||||
|
||||
.note-tool:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.note-tool--active {
|
||||
color: #e0c070;
|
||||
background: rgba(224, 192, 112, 0.12);
|
||||
border-color: rgba(224, 192, 112, 0.28);
|
||||
}
|
||||
|
||||
.note-tool-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: #2a2518;
|
||||
margin: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-card-editor {
|
||||
padding: 0.85rem 1rem;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap {
|
||||
outline: none;
|
||||
color: #f0ead8;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap ul,
|
||||
.note-card-editor .tiptap ol {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap strong {
|
||||
color: #f4edd5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap em {
|
||||
color: #d4c898;
|
||||
}
|
||||
|
||||
.note-card-editor .tiptap .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #4a4535;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ── Notes page rich content display ─────────────────────────────── */
|
||||
.study-note-rich-content {
|
||||
color: #d8cca8;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.study-note-rich-content p {
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.study-note-rich-content ul,
|
||||
.study-note-rich-content ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.study-note-rich-content li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.study-note-rich-content strong {
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.study-note-rich-content em {
|
||||
color: #d4c898;
|
||||
}
|
||||
|
||||
.study-auth-box label {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -2537,6 +2892,25 @@
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.study-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(12, 11, 10, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.study-modal-box {
|
||||
width: min(540px, 100%);
|
||||
background: #1b1a16;
|
||||
border: 1px solid #3c3a31;
|
||||
border-radius: 18px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
|
||||
.study-detail-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
@@ -2798,6 +3172,15 @@
|
||||
background: var(--brand-gold-light, #e0c070);
|
||||
}
|
||||
|
||||
@keyframes buffering-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.episode-audio-player__play--buffering {
|
||||
animation: buffering-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.episode-audio-player__play svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -3250,6 +3633,12 @@
|
||||
margin: 0.25rem 0 0 !important;
|
||||
}
|
||||
|
||||
.study-signup-field-error {
|
||||
font-size: 0.82rem;
|
||||
color: #e57373;
|
||||
margin: 0.2rem 0 0.1rem;
|
||||
}
|
||||
|
||||
.study-signup-success {
|
||||
font-size: 1.05rem !important;
|
||||
color: #81c784 !important;
|
||||
@@ -3658,6 +4047,26 @@
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.download-clear-link {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-muted);
|
||||
padding: 0.35rem 0.5rem;
|
||||
transition: color 140ms ease;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.download-clear-link:hover {
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.download-empty-state {
|
||||
text-align: center;
|
||||
color: var(--brand-muted);
|
||||
@@ -5457,7 +5866,10 @@
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: none;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
@@ -5466,13 +5878,17 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
padding: 0 1rem;
|
||||
background: rgba(10, 10, 8, 0.98);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
|
||||
transition: max-height 0.28s ease, opacity 0.22s ease, padding 0.28s ease;
|
||||
}
|
||||
|
||||
.header-nav--open {
|
||||
display: flex;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.header-nav .header-nav-link {
|
||||
|
||||
+37
-13
@@ -795,6 +795,15 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{activeTag !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="download-clear-link"
|
||||
onClick={() => { setActiveTag(null); setActiveType('all') }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1497,20 +1506,24 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
<h1 className="episode-detail-title">{episode.title}</h1>
|
||||
{episode.summary && <p className="episode-detail-summary">{episode.summary}</p>}
|
||||
|
||||
{resolvedEmbedUrl && (
|
||||
{(episode.embedUrl || episode.url) && (
|
||||
<div className="episode-detail-embed">
|
||||
{resolvedEmbedUrl.includes('spotify.com') ? (
|
||||
<iframe
|
||||
src={resolvedEmbedUrl}
|
||||
title={episode.title}
|
||||
width="100%"
|
||||
height="152"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
{resolvedEmbedUrl ? (
|
||||
resolvedEmbedUrl.includes('spotify.com') ? (
|
||||
<iframe
|
||||
src={resolvedEmbedUrl}
|
||||
title={episode.title}
|
||||
width="100%"
|
||||
height="152"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||||
)
|
||||
) : (
|
||||
<EpisodeAudioPlayer src={resolvedEmbedUrl} title={episode.title} size="full" spotifyUrl={episode.url || content.platformSpotifyUrl} />
|
||||
<div className="episode-embed-skeleton" aria-label="Loading player…" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1870,7 +1883,18 @@ function PublicCertificatePage() {
|
||||
)
|
||||
|
||||
if (status === 'loading') {
|
||||
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate…</p></div></main>
|
||||
return (
|
||||
<main className="thanks-page">
|
||||
<div className="cert-skeleton-wrap" aria-busy="true" aria-label="Loading certificate">
|
||||
<div className="cert-skeleton-card">
|
||||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--title" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--medium" />
|
||||
<div className="cert-skeleton-line cert-skeleton-line--short" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (status === 'not-found' || !cert) {
|
||||
return (
|
||||
|
||||
+345
-135
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
||||
@@ -6,6 +6,7 @@ import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||
import { StudyCertificate } from './components/StudyCertificate'
|
||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||
import { NoteCard } from './components/NoteCard'
|
||||
|
||||
type Props = { content: SiteContent }
|
||||
|
||||
@@ -83,6 +84,7 @@ type StudyCommunityPost = {
|
||||
message: string
|
||||
createdAt: string
|
||||
replies: StudyCommunityReply[]
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
@@ -177,6 +179,11 @@ function isSectionReleased(section: StudySection): boolean {
|
||||
function getReleaseDateDisplay(section: StudySection): string {
|
||||
const date = getSectionReleaseDate(section)
|
||||
if (!date) return ''
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffDays === 1) return 'tomorrow'
|
||||
if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
@@ -184,6 +191,32 @@ function getNoteId(studySlug: string, sectionId: string) {
|
||||
return `${studySlug}--${sectionId}`
|
||||
}
|
||||
|
||||
const NOTE_ANCHORS = [
|
||||
{ id: 'scripture', label: 'Scripture Text' },
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
{ id: 'questions', label: 'Reflection Questions' },
|
||||
] as const
|
||||
|
||||
type NoteAnchorId = typeof NOTE_ANCHORS[number]['id'] | 'general'
|
||||
|
||||
const ANCHOR_LABELS: Record<string, string> = {
|
||||
scripture: 'Scripture Text',
|
||||
commentary: 'Commentary',
|
||||
questions: 'Reflection Questions',
|
||||
general: 'General Notes',
|
||||
}
|
||||
|
||||
function parseNoteMap(raw: string): Record<string, string> {
|
||||
if (!raw?.trim()) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {}
|
||||
return { general: raw }
|
||||
}
|
||||
|
||||
function getDisplayName(auth: StudyAuthState) {
|
||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||
}
|
||||
@@ -259,16 +292,34 @@ function CommunityBoard({
|
||||
setSubmitting(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
const optimisticId = `pending-${Date.now()}`
|
||||
const optimisticPost: StudyCommunityPost = {
|
||||
id: optimisticId,
|
||||
studySlug: study.slug,
|
||||
sectionId: activeSection,
|
||||
authorName: communityDisplayName,
|
||||
authorAvatarUrl: auth.avatarUrl || undefined,
|
||||
message: newPost,
|
||||
createdAt: new Date().toISOString(),
|
||||
replies: [],
|
||||
pending: true,
|
||||
}
|
||||
setPosts(prev => [optimisticPost, ...prev])
|
||||
const postedMessage = newPost
|
||||
setNewPost('')
|
||||
try {
|
||||
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: newPost }),
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
|
||||
})
|
||||
if (data.post) setPosts(prev => [data.post!, ...prev])
|
||||
setNewPost('')
|
||||
setMessage('Posted to the community.')
|
||||
setPosts(prev => data.post
|
||||
? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
|
||||
: prev.filter(p => p.id !== optimisticId))
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
setPosts(prev => prev.filter(p => p.id !== optimisticId))
|
||||
setNewPost(postedMessage)
|
||||
setError(err instanceof Error ? err.message : 'Unable to post right now.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -350,8 +401,13 @@ function CommunityBoard({
|
||||
rows={4}
|
||||
value={newPost}
|
||||
onChange={e => setNewPost(e.target.value)}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}...` : 'Share a thought with the community...'}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
|
||||
@@ -373,7 +429,7 @@ function CommunityBoard({
|
||||
) : visiblePosts.map(post => {
|
||||
const postSection = study.sections.find(s => s.id === post.sectionId)
|
||||
return (
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d' }}>
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
{post.authorAvatarUrl ? (
|
||||
@@ -407,8 +463,13 @@ function CommunityBoard({
|
||||
rows={3}
|
||||
value={replyDrafts[post.id] ?? ''}
|
||||
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
|
||||
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
||||
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden' }}
|
||||
/>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
|
||||
</div>
|
||||
@@ -474,6 +535,7 @@ export function StudyLandingPage({ content }: Props) {
|
||||
const [enrollBusySlug, setEnrollBusySlug] = useState('')
|
||||
const [enrollMessage, setEnrollMessage] = useState('')
|
||||
const [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
|
||||
const [confirmLeaveSlug, setConfirmLeaveSlug] = useState<string | null>(null)
|
||||
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||
|
||||
const studies = getStudies(content)
|
||||
@@ -647,14 +709,27 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
|
||||
</button>
|
||||
{confirmLeaveSlug === studyInfo.slug ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
|
||||
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => setConfirmLeaveSlug(studyInfo.slug)}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -675,14 +750,27 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
|
||||
</button>
|
||||
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
|
||||
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
|
||||
disabled={enrollBusySlug === studyInfo.slug}
|
||||
onClick={() => studyInfo.enrolled ? setConfirmLeaveSlug(studyInfo.slug) : handleEnrollmentToggle(studyInfo.slug, false)}
|
||||
>
|
||||
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -907,6 +995,8 @@ export function StudyLandingPage({ content }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
|
||||
|
||||
export function StudySignupPage() {
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<'signup' | 'login'>('login')
|
||||
@@ -917,6 +1007,7 @@ export function StudySignupPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [done, setDone] = useState(false)
|
||||
const [loggedInAs, setLoggedInAs] = useState('')
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
|
||||
// 2FA state
|
||||
const [totpPendingToken, setTotpPendingToken] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
@@ -939,7 +1030,7 @@ export function StudySignupPage() {
|
||||
setMessage('Please enter your email and password.')
|
||||
return
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
||||
if (!EMAIL_REGEX.test(email.trim())) {
|
||||
setMessage('Please enter a valid email address.')
|
||||
return
|
||||
}
|
||||
@@ -994,11 +1085,11 @@ export function StudySignupPage() {
|
||||
|
||||
{done ? (
|
||||
<>
|
||||
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>. Your notes are ready on every lesson.</p>
|
||||
<div className="study-signup-actions">
|
||||
<button type="button" className="btn-primary" onClick={() => navigate('/study')}>Go to Study Hub</button>
|
||||
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
|
||||
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>.</p>
|
||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Head to the study hub to enroll in a track and begin your first lesson.</p>
|
||||
<div className="study-signup-actions" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '0.75rem' }}>
|
||||
<button type="button" className="btn-primary" style={{ justifyContent: 'center' }} onClick={() => navigate('/study')}>Browse & Enroll in a Study →</button>
|
||||
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
|
||||
</div>
|
||||
</>
|
||||
) : totpPendingToken ? (
|
||||
@@ -1059,21 +1150,37 @@ export function StudySignupPage() {
|
||||
id="signup-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onChange={e => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })) }}
|
||||
onBlur={() => {
|
||||
if (email.trim() && !EMAIL_REGEX.test(email.trim())) {
|
||||
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
|
||||
} else {
|
||||
setFieldErrors(prev => ({ ...prev, email: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="your@email.com"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.email && <p className="study-signup-field-error">{fieldErrors.email}</p>}
|
||||
<label htmlFor="signup-password">Password</label>
|
||||
<input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onChange={e => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })) }}
|
||||
onBlur={() => {
|
||||
if (mode === 'signup' && password && password.length < 8) {
|
||||
setFieldErrors(prev => ({ ...prev, password: 'Password must be at least 8 characters.' }))
|
||||
} else {
|
||||
setFieldErrors(prev => ({ ...prev, password: undefined }))
|
||||
}
|
||||
}}
|
||||
placeholder="At least 8 characters"
|
||||
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
|
||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
/>
|
||||
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
|
||||
{mode === 'signup' && (
|
||||
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
|
||||
<input
|
||||
@@ -1381,19 +1488,24 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const [enrollBusy, setEnrollBusy] = useState(false)
|
||||
const [enrollMessage, setEnrollMessage] = useState('')
|
||||
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [noteMap, setNoteMap] = useState<Record<string, string>>({})
|
||||
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
|
||||
const [noteLoading, setNoteLoading] = useState(false)
|
||||
const [noteSaving, setNoteSaving] = useState(false)
|
||||
const [noteMessage, setNoteMessage] = useState('')
|
||||
const [notesModalOpen, setNotesModalOpen] = useState(false)
|
||||
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string } | null>(null)
|
||||
const noteMapDirtyRef = useRef(false)
|
||||
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
|
||||
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
|
||||
const [progressSaving, setProgressSaving] = useState(false)
|
||||
const [progressMessage, setProgressMessage] = useState('')
|
||||
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
|
||||
const [showCelebration, setShowCelebration] = useState(false)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
||||
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
||||
const [_checkpointReflection, _setCheckpointReflection] = useState('')
|
||||
|
||||
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
||||
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
||||
const lessonAudioEmbedUrl = useMemo(() => {
|
||||
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
|
||||
if (!trimmed) return ''
|
||||
@@ -1461,7 +1573,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId || !isEnrolled) {
|
||||
setNoteText('')
|
||||
setNoteMap({})
|
||||
noteMapDirtyRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1472,7 +1585,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setNoteText(data.note ?? '')
|
||||
setNoteMap(parseNoteMap(data.note ?? ''))
|
||||
noteMapDirtyRef.current = false
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
@@ -1515,17 +1629,22 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNote(anchor: NoteAnchorId, html: string) {
|
||||
noteMapDirtyRef.current = true
|
||||
setNoteMap(prev => ({ ...prev, [anchor]: html }))
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteId || !canSaveNote) return
|
||||
setNoteSaving(true)
|
||||
setNoteMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
||||
})
|
||||
setNoteText(data.note ?? '')
|
||||
noteMapDirtyRef.current = false
|
||||
setNoteMessage('Notes saved.')
|
||||
} catch (err) {
|
||||
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
|
||||
@@ -1534,23 +1653,54 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function closeNotesModal() {
|
||||
setNotesModalOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!notesModalOpen) return
|
||||
if (!openAnchor) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeNotesModal()
|
||||
if (e.key === 'Escape') setOpenAnchor(null)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [notesModalOpen])
|
||||
}, [openAnchor])
|
||||
|
||||
function appendSelectedTextToNotes(selectedText: string) {
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId || noteLoading) return
|
||||
if (!noteMapDirtyRef.current) return
|
||||
const timer = setTimeout(async () => {
|
||||
if (!noteMapDirtyRef.current) return
|
||||
noteMapDirtyRef.current = false
|
||||
try {
|
||||
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
||||
})
|
||||
setAutoSaveMsg('Auto-saved.')
|
||||
setTimeout(() => setAutoSaveMsg(''), 3000)
|
||||
} catch {
|
||||
noteMapDirtyRef.current = true
|
||||
}
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [noteMap, noteId, auth.authenticated, noteLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCelebration) return
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setShowCelebration(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [showCelebration])
|
||||
|
||||
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
||||
if (!selectedText.trim()) return
|
||||
setNoteText(prev => prev ? `${prev.trim()}\n\n${selectedText.trim()}` : selectedText.trim())
|
||||
setNotesModalOpen(true)
|
||||
const existing = noteMap[anchor] ?? ''
|
||||
const appended = existing
|
||||
? `${existing}<p>${selectedText.trim()}</p>`
|
||||
: `<p>${selectedText.trim()}</p>`
|
||||
updateNote(anchor, appended)
|
||||
setOpenAnchor(anchor)
|
||||
}
|
||||
|
||||
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||
@@ -1562,7 +1712,9 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText })
|
||||
const anchorEl = (e.target as Element).closest('[data-note-anchor]')
|
||||
const anchor = (anchorEl?.getAttribute('data-note-anchor') ?? 'general') as NoteAnchorId
|
||||
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText, anchor })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1572,6 +1724,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
return () => window.removeEventListener('mousedown', handleClick)
|
||||
}, [contextNoteMenu])
|
||||
|
||||
const releasedSectionCount = sections.filter(s => isSectionReleased(s)).length
|
||||
|
||||
function checkAndShowCelebration(newIds: string[], wasAlreadyComplete: boolean) {
|
||||
if (wasAlreadyComplete) return
|
||||
if (releasedSectionCount > 0 && newIds.length >= releasedSectionCount) {
|
||||
setShowCelebration(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCheckpoint() {
|
||||
if (!study || !section?.id || progressSaving) return
|
||||
const checkpointQuestions = section.checkpointQuestions ?? []
|
||||
@@ -1588,8 +1749,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
||||
setCompletedSectionIds(newIds)
|
||||
setProgressMessage('')
|
||||
checkAndShowCelebration(newIds, lessonCompleted)
|
||||
} catch (err) {
|
||||
setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.')
|
||||
} finally {
|
||||
@@ -1689,13 +1852,36 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
|
||||
return (
|
||||
<main className="study-section-page" aria-label={section.title}>
|
||||
{section.announcement && (
|
||||
{section.announcement && !bannerDismissed && (
|
||||
<div className="study-lesson-announcement-banner" role="alert">
|
||||
<span className="study-lesson-announcement-icon" aria-hidden="true">📢</span>
|
||||
<div className="study-lesson-announcement-body">
|
||||
<strong className="study-lesson-announcement-label">Announcement</strong>
|
||||
<p className="study-lesson-announcement-text">{section.announcement}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="study-lesson-announcement-close"
|
||||
aria-label="Dismiss announcement"
|
||||
onClick={() => {
|
||||
setBannerDismissed(true)
|
||||
localStorage.setItem('study-banner-dismissed-v1', '1')
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCelebration && study && (
|
||||
<div className="study-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="celebrate-heading">
|
||||
<div className="study-modal-box" style={{ textAlign: 'center', maxWidth: '480px' }}>
|
||||
<p style={{ fontSize: '2.5rem', margin: '0 0 0.5rem' }}>🎉</p>
|
||||
<h2 id="celebrate-heading" style={{ margin: '0 0 0.75rem' }}>You finished {study.title}!</h2>
|
||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem' }}>Your certificate of completion is ready on the study dashboard.</p>
|
||||
<div className="study-auth-actions" style={{ justifyContent: 'center' }}>
|
||||
<Link to="/study/account" className="btn-primary" onClick={() => setShowCelebration(false)}>View Certificate</Link>
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowCelebration(false)}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
||||
@@ -1729,12 +1915,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="scripture">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
|
||||
title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
|
||||
aria-pressed={openAnchor === 'scripture'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Scripture Text</h2>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
|
||||
{isEnrolled && openAnchor === 'scripture' && (
|
||||
<NoteCard
|
||||
anchorLabel="Scripture Text"
|
||||
html={noteMap['scripture'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('scripture', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="commentary">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
|
||||
title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
|
||||
aria-pressed={openAnchor === 'commentary'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Instructor Commentary</h2>
|
||||
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
||||
<p key={i} className="study-detail-copy">
|
||||
@@ -1743,9 +1956,27 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
{isEnrolled && openAnchor === 'commentary' && (
|
||||
<NoteCard
|
||||
anchorLabel="Commentary"
|
||||
html={noteMap['commentary'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('commentary', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<article className="study-class-block study-class-block--anchored" data-note-anchor="questions">
|
||||
{isEnrolled && (
|
||||
<button
|
||||
type="button"
|
||||
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
|
||||
title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
|
||||
aria-pressed={openAnchor === 'questions'}
|
||||
>✎</button>
|
||||
)}
|
||||
<h2>Reflection Questions</h2>
|
||||
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
|
||||
Work through these on your own, then share your thoughts in the Discussion below.
|
||||
@@ -1760,6 +1991,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
|
||||
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
{isEnrolled && openAnchor === 'questions' && (
|
||||
<NoteCard
|
||||
anchorLabel="Reflection Questions"
|
||||
html={noteMap['questions'] ?? ''}
|
||||
autoSaveMsg={autoSaveMsg}
|
||||
onChange={html => updateNote('questions', html)}
|
||||
onClose={() => setOpenAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
|
||||
@@ -1776,13 +2016,16 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
onClick={async () => {
|
||||
if (!study || !section?.id) return
|
||||
setProgressSaving(true)
|
||||
const wasComplete = lessonCompleted
|
||||
try {
|
||||
const method = lessonCompleted ? 'DELETE' : 'POST'
|
||||
const method = wasComplete ? 'DELETE' : 'POST'
|
||||
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
|
||||
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
|
||||
{ method }
|
||||
)
|
||||
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
|
||||
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
|
||||
setCompletedSectionIds(newIds)
|
||||
if (!wasComplete) checkAndShowCelebration(newIds, false)
|
||||
} catch { /* ignore */ }
|
||||
finally { setProgressSaving(false) }
|
||||
}}
|
||||
@@ -1886,12 +2129,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
{auth.checked && auth.authenticated && (
|
||||
<div className="study-notes-box">
|
||||
<p className="study-notes-user">Signed in as {auth.username}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
|
||||
<button type="button" className="btn-primary" onClick={() => setNotesModalOpen(true)}>Open Notes</button>
|
||||
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||
</div>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b', fontSize: '0.9rem' }}>Highlight scripture, commentary, or Greek word text, right click, and add it to your notes.</p>
|
||||
{noteLoading ? <p style={{ marginTop: '0.75rem' }}>Loading notes…</p> : null}
|
||||
<Link to="/study/account" className="btn-secondary" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>My Account</Link>
|
||||
{noteLoading ? (
|
||||
<p style={{ color: '#7a7060', fontSize: '0.85rem', marginTop: '0.5rem' }}>Loading notes…</p>
|
||||
) : (
|
||||
<>
|
||||
{savedNoteCount > 0 && (
|
||||
<p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginBottom: '0.5rem' }}>
|
||||
{savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓
|
||||
</p>
|
||||
)}
|
||||
<div className="note-anchor-list">
|
||||
{NOTE_ANCHORS.map(({ id, label }) => {
|
||||
const hasNote = !!(noteMap[id]?.trim())
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`note-anchor-list-item${hasNote ? ' note-anchor-list-item--has' : ''}${openAnchor === id ? ' note-anchor-list-item--open' : ''}`}
|
||||
onClick={() => setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{hasNote && <span className="note-anchor-dot" aria-hidden="true">●</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#7a7060', fontSize: '0.82rem' }}>Click a section above to add notes. Right-click lesson text to append a selection.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
|
||||
@@ -1930,7 +2196,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<p style={{ margin: '0 0 0.75rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add selected text to notes:</p>
|
||||
<p style={{ margin: '0 0 0.35rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add to <strong style={{ color: '#c9a84c' }}>{ANCHOR_LABELS[contextNoteMenu.anchor] ?? 'Notes'}</strong>:</p>
|
||||
<div style={{ marginBottom: '0.75rem', maxHeight: '100px', overflow: 'auto', color: '#f0ead8', fontSize: '0.9rem' }}>
|
||||
{contextNoteMenu.text}
|
||||
</div>
|
||||
@@ -1938,7 +2204,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => {
|
||||
appendSelectedTextToNotes(contextNoteMenu.text)
|
||||
appendSelectedTextToNotes(contextNoteMenu.text, contextNoteMenu.anchor)
|
||||
setContextNoteMenu(null)
|
||||
}}
|
||||
>
|
||||
@@ -1947,73 +2213,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notesModalOpen && (
|
||||
<div
|
||||
className="study-modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Lesson notes editor"
|
||||
onClick={closeNotesModal}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(12, 11, 10, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 40,
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="study-modal"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(760px, 100%)',
|
||||
maxHeight: 'min(90vh, 900px)',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: '#1b1a16',
|
||||
border: '1px solid #3c3a31',
|
||||
borderRadius: '18px',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>Lesson Notes</h2>
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b' }}>Save notes for this lesson and paste selections from the page.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Close</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={12}
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
placeholder="Write your notes here..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '280px',
|
||||
padding: '1rem',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #3f3b2f',
|
||||
background: '#14130f',
|
||||
color: '#f0ead8',
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" className="btn-primary" onClick={saveNote} disabled={!canSaveNote}>
|
||||
{noteSaving ? 'Saving...' : 'Save Notes'}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Cancel</button>
|
||||
</div>
|
||||
{(noteMessage || progressMessage) && <p className="study-note-status" style={{ marginTop: '1rem' }}>{noteMessage || progressMessage}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -2189,8 +2388,19 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
||||
<p className="study-module-lesson">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved Note'}</p>
|
||||
<h3>{active.section?.title ?? active.sectionId}</h3>
|
||||
<p className="study-detail-reference">{active.section ? active.section.reference : 'Lesson reference unavailable'}</p>
|
||||
<p className="study-module-focus">Your Note</p>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{active.note}</p>
|
||||
{(() => {
|
||||
const anchorMap = parseNoteMap(active.note)
|
||||
const hasContent = Object.values(anchorMap).some(v => v?.trim())
|
||||
if (!hasContent) return <p className="study-detail-copy">No notes saved for this lesson yet.</p>
|
||||
return Object.entries(anchorMap).map(([key, html]) => (
|
||||
html?.trim() ? (
|
||||
<div key={key} style={{ marginBottom: '1.25rem' }}>
|
||||
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
|
||||
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
) : null
|
||||
))
|
||||
})()}
|
||||
{active.section && <Link to={`/study/${study.slug}/${active.section.id}`} className="btn-secondary">Open Lesson</Link>}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -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