Add release date and episode # columns to admin lesson list with inline date editing; use local time for release dates; v2.8.2
Publish Container / build (push) Successful in 18m28s
Publish Container / build (push) Successful in 18m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ff0b32b97
commit
b1695b2b59
6 files changed
+58
-8
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.8.1",
|
"version": "2.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ function sanitizeColossiansStudySections(value) {
|
|||||||
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
|
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
|
||||||
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
|
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
|
||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||||||
|
episodeNumber: typeof item.episodeNumber === 'string' || typeof item.episodeNumber === 'number' ? String(item.episodeNumber).trim().slice(0, 20) : '',
|
||||||
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
|
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
|
||||||
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
|
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
|
||||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||||
|
|||||||
@@ -312,9 +312,11 @@ export function isSectionReleased(section) {
|
|||||||
|
|
||||||
export function redactUnreleasedSection(section) {
|
export function redactUnreleasedSection(section) {
|
||||||
if (!section || typeof section !== 'object') return section
|
if (!section || typeof section !== 'object') return section
|
||||||
if (isSectionReleased(section)) return section
|
// episodeNumber is an admin-only reference and is never sent to the public site
|
||||||
|
const { episodeNumber: _episodeNumber, ...publicSection } = section
|
||||||
|
if (isSectionReleased(section)) return publicSection
|
||||||
return {
|
return {
|
||||||
...section,
|
...publicSection,
|
||||||
passageText: '',
|
passageText: '',
|
||||||
commentary: '',
|
commentary: '',
|
||||||
greekNotes: [],
|
greekNotes: [],
|
||||||
|
|||||||
+30
-5
@@ -14,22 +14,43 @@ import InboxPanel, { type InboxSubmission } from './InboxPanel'
|
|||||||
import AssetManager, { type AdminAsset } from './AssetManager'
|
import AssetManager, { type AdminAsset } from './AssetManager'
|
||||||
|
|
||||||
|
|
||||||
|
function toLocalInputValue(iso?: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLocalInputValue(val: string): string {
|
||||||
|
return val ? new Date(val).toISOString() : ''
|
||||||
|
}
|
||||||
|
|
||||||
interface SortableLessonRowProps {
|
interface SortableLessonRowProps {
|
||||||
section: ColossiansStudySection
|
section: ColossiansStudySection
|
||||||
study: StudyProgram
|
study: StudyProgram
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
removeStudySection: (studyId: string, sectionId: string) => void
|
removeStudySection: (studyId: string, sectionId: string) => void
|
||||||
|
onReleaseChange: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableLessonRow({ section, study, onEdit, removeStudySection }: SortableLessonRowProps) {
|
function SortableLessonRow({ section, study, onEdit, removeStudySection, onReleaseChange }: SortableLessonRowProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
||||||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
|
||||||
const isLive = section.releasedAt ? new Date(section.releasedAt) <= new Date() : false
|
const isLive = section.releasedAt ? new Date(section.releasedAt) <= new Date() : false
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="study-lesson-row">
|
<div ref={setNodeRef} style={style} className="study-lesson-row">
|
||||||
<span className="study-lesson-drag" {...attributes} {...listeners} title="Drag to reorder">⠿</span>
|
<span className="study-lesson-drag" {...attributes} {...listeners} title="Drag to reorder">⠿</span>
|
||||||
|
<span className="study-lesson-ep">{section.episodeNumber || '—'}</span>
|
||||||
<span className="study-lesson-ref">{section.reference || '—'}</span>
|
<span className="study-lesson-ref">{section.reference || '—'}</span>
|
||||||
<span className="study-lesson-title">{section.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
<span className="study-lesson-title">{section.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="study-lesson-release"
|
||||||
|
aria-label={`Release date for ${section.title || 'lesson'}`}
|
||||||
|
value={toLocalInputValue(section.releasedAt)}
|
||||||
|
onChange={e => onReleaseChange(fromLocalInputValue(e.target.value))}
|
||||||
|
/>
|
||||||
<span className={`study-lesson-pill ${isLive ? 'live' : 'draft'}`}>{isLive ? 'Live' : 'Draft'}</span>
|
<span className={`study-lesson-pill ${isLive ? 'live' : 'draft'}`}>{isLive ? 'Live' : 'Draft'}</span>
|
||||||
<div className="study-lesson-actions">
|
<div className="study-lesson-actions">
|
||||||
<button type="button" className="btn-admin-ghost" onClick={onEdit}>Edit</button>
|
<button type="button" className="btn-admin-ghost" onClick={onEdit}>Edit</button>
|
||||||
@@ -5043,8 +5064,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
|
|
||||||
<div className="study-lesson-list-header">
|
<div className="study-lesson-list-header">
|
||||||
<span style={{ width: 28 }} />
|
<span style={{ width: 28 }} />
|
||||||
|
<span style={{ width: 44 }}>Ep #</span>
|
||||||
<span style={{ width: 80 }}>Reference</span>
|
<span style={{ width: 80 }}>Reference</span>
|
||||||
<span style={{ flex: 1 }}>Title</span>
|
<span style={{ flex: 1 }}>Title</span>
|
||||||
|
<span style={{ width: 190 }}>Release</span>
|
||||||
<span style={{ width: 60 }}>Status</span>
|
<span style={{ width: 60 }}>Status</span>
|
||||||
<span style={{ width: 160 }} />
|
<span style={{ width: 160 }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -5075,6 +5098,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
study={activeStudy}
|
study={activeStudy}
|
||||||
onEdit={() => setSelectedLessonId(section.id)}
|
onEdit={() => setSelectedLessonId(section.id)}
|
||||||
removeStudySection={removeStudySection}
|
removeStudySection={removeStudySection}
|
||||||
|
onReleaseChange={value => updateStudySection(activeStudy.id, section.id, 'releasedAt', value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -5109,6 +5133,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-array-fields">
|
<div className="admin-array-fields">
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`sl-episode-${activeLesson.id}`}>Episode # (admin reference only)</label>
|
||||||
|
<input id={`sl-episode-${activeLesson.id}`} type="text" value={activeLesson.episodeNumber ?? ''} placeholder="12" maxLength={20} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'episodeNumber', e.target.value)} />
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-chapter-${activeLesson.id}`}>Chapter</label>
|
<label htmlFor={`sl-chapter-${activeLesson.id}`}>Chapter</label>
|
||||||
<select id={`sl-chapter-${activeLesson.id}`} value={activeLesson.chapter} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'chapter', Number(e.target.value) || 1)}>
|
<select id={`sl-chapter-${activeLesson.id}`} value={activeLesson.chapter} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'chapter', Number(e.target.value) || 1)}>
|
||||||
@@ -5137,10 +5165,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
||||||
<input id={`sl-released-${activeLesson.id}`} type="datetime-local" value={activeLesson.releasedAt ? new Date(activeLesson.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
|
<input id={`sl-released-${activeLesson.id}`} type="datetime-local" value={toLocalInputValue(activeLesson.releasedAt)} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'releasedAt', fromLocalInputValue(e.target.value))} />
|
||||||
const val = e.target.value
|
|
||||||
updateStudySection(activeStudy.id, activeLesson.id, 'releasedAt', val ? new Date(val + ':00Z').toISOString() : '')
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-summary-${activeLesson.id}`}>Short Summary</label>
|
<label htmlFor={`sl-summary-${activeLesson.id}`}>Short Summary</label>
|
||||||
|
|||||||
+21
@@ -6428,6 +6428,14 @@
|
|||||||
}
|
}
|
||||||
.study-lesson-drag:active { cursor: grabbing; }
|
.study-lesson-drag:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.study-lesson-ep {
|
||||||
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--admin-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.study-lesson-ref {
|
.study-lesson-ref {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -6436,6 +6444,19 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.study-lesson-release {
|
||||||
|
width: 190px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--admin-text);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--admin-card-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
.study-lesson-title {
|
.study-lesson-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ColossiansStudySection = {
|
|||||||
checkpointPrompt?: string
|
checkpointPrompt?: string
|
||||||
checkpointQuestions?: string[]
|
checkpointQuestions?: string[]
|
||||||
releasedAt?: string
|
releasedAt?: string
|
||||||
|
episodeNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function section(
|
function section(
|
||||||
|
|||||||
Reference in new issue
Block a user