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
+56 -1
View File
@@ -8,6 +8,9 @@
"name": "siteforge",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"chart.js": "^4.4.0",
"docx": "^9.6.1",
"express": "^5.2.1",
@@ -280,6 +283,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
@@ -5766,7 +5822,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
+3
View File
@@ -13,6 +13,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"chart.js": "^4.4.0",
"docx": "^9.6.1",
"express": "^5.2.1",
+134 -75
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>
)}
<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 => (
<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>
<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>