Add drag-and-drop lesson reordering for admin study sections

This commit is contained in:
nmemmert
2026-05-26 10:09:43 -04:00
parent c1fcf66ef8
commit a58d75c425
3 changed files with 195 additions and 78 deletions
+136 -77
View File
@@ -1,10 +1,110 @@
import { useEffect, useRef, useState } from 'react'
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
interface SortableLessonSectionProps {
section: ColossiansStudySection
study: StudyProgram
updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void
removeStudySection: (studyId: string, sectionId: string) => void
}
function SortableLessonSection({ section, study, updateStudySection, removeStudySection }: SortableLessonSectionProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
background: isDragging ? '#f8f7f2' : undefined,
border: isDragging ? '2px solid #e0c070' : undefined,
marginBottom: '0.5rem',
}
return (
<details ref={setNodeRef} style={style} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary" {...attributes} {...listeners} style={{ cursor: 'grab', userSelect: 'none' }}>
<div>
<strong>Lesson {section.reference || section.id}</strong>
<p>{section.title || 'Untitled section'}</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary" onClick={e => e.stopPropagation()}>Preview</a>
<span className="admin-collapsible-hint">Expand to edit lesson</span>
</div>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
const val = e.target.value;
if (val) {
const date = new Date(val + ':00Z');
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
} else {
updateStudySection(study.id, section.id, 'releasedAt', '');
}
}} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-focus-${study.id}-${section.id}`}>Focus Question</label>
<input id={`study-section-focus-${study.id}-${section.id}`} type="text" value={section.focusQuestion ?? ''} placeholder="Shown on the lesson card (defaults to first study question)" onChange={e => updateStudySection(study.id, section.id, 'focusQuestion', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
</div>
</details>
)
}
interface Props {
content: SiteContent
onSave: (c: SiteContent) => void
@@ -459,6 +559,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null)
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
@@ -3473,83 +3574,41 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<p className="admin-stats-note">No sections yet for this study.</p>
)}
{(study.sections ?? []).map(section => (
<details key={section.id} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary">
<div>
<strong>Lesson {section.reference || section.id}</strong>
<p>{section.title || 'Untitled section'}</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary" onClick={e => e.stopPropagation()}>Preview</a>
<span className="admin-collapsible-hint">Expand to edit lesson</span>
</div>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
const val = e.target.value;
if (val) {
const date = new Date(val + ':00Z');
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
} else {
updateStudySection(study.id, section.id, 'releasedAt', '');
}
}} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-focus-${study.id}-${section.id}`}>Focus Question</label>
<input id={`study-section-focus-${study.id}-${section.id}`} type="text" value={section.focusQuestion ?? ''} placeholder="Shown on the lesson card (defaults to first study question)" onChange={e => updateStudySection(study.id, section.id, 'focusQuestion', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
</div>
</details>
))}
<DndContext
sensors={dragSensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
const overId = over?.id
if (!overId || active.id === overId) return
setForm(f => {
const studies = f.studies.map(s => {
if (s.id !== study.id) return s
const oldIndex = s.sections.findIndex(sec => sec.id === active.id)
const newIndex = s.sections.findIndex(sec => sec.id === overId)
if (oldIndex === -1 || newIndex === -1) return s
const newSections = arrayMove(s.sections, oldIndex, newIndex)
return { ...s, sections: newSections }
})
return { ...f, studies }
})
}}
>
<SortableContext
items={(study.sections ?? []).map(section => section.id)}
strategy={verticalListSortingStrategy}
>
{(study.sections ?? []).map(section => (
<SortableLessonSection
key={section.id}
section={section}
study={study}
updateStudySection={updateStudySection}
removeStudySection={removeStudySection}
/>
))}
</SortableContext>
</DndContext>
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
</div>