Add drag-and-drop lesson reordering for admin study sections
This commit is contained in:
+136
-77
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user